diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 4783b10cf6da0c322c39d1ee8e91eb90eb0b2138..8506b86a54fda2e948e3ae2168599a1040e8f778 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -11,7 +11,10 @@ .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline %h1.home-panel-title.prepend-top-8.append-bottom-5.qa-project-name - = @project.name + - if Feature.enabled?(:test_project_name, @project) + = "The feature is on this project!" + - else + = @project.name %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) .home-panel-metadata.d-flex.flex-wrap.text-secondary diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 814ea551e19b63b78c66f42d0aa6220b169ea11e..f3393eb51b8b4e92ab0bb7ed1a6710bd06caa222 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -468,6 +468,13 @@ production: &base # enabled: true # primary_api_url: http://localhost:5000/ # internal address to the primary registry, will be used by GitLab to directly communicate with primary registry API + ## Feature Flag https://docs.gitlab.com/ee/user/project/operations/feature_flags.html + unleash: + # enabled: false + # url: https://gitlab.com/api/v4/feature_flags/unleash/ + # app_name: gitlab.com # Environment name of your GitLab instance + # instance_id: INSTNACE_ID + # personal_access_token: PAT # Used for controlling flags on the project # # 2. GitLab CI settings diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index fbe6c21e53dfa5a6c40c6ffdb8968fd3d1dfda5c..1332d425373e517a8514a2becea9f641c95f97a9 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -310,6 +310,15 @@ Settings.geo.registry_replication['enabled'] ||= false end +# +# Unleash +# +Settings['unleash'] ||= Settingslogic.new({}) +Settings.unleash['enabled'] = false if Settings.unleash['enabled'].nil? +Settings.unleash['app_name'] = Rails.env if Settings.unleash['app_name'].nil? +Settings.unleash['instance_id'] = ENV['UNLEASH_INSTANCE_ID'] if Settings.unleash['instance_id'].nil? +Settings.unleash['personal_access_token'] = ENV['UNLEASH_PERSONAL_ACCESS_TOKEN'] if Settings.unleash['personal_access_token'].nil? + # # External merge request diffs # diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 80cab7273e5ccd9ac994cba58baa6300c809e5d6..73e0681081880cdec99653d1b92c8eb8ddc10c38 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -1 +1 @@ -Feature.register_feature_groups +FeatureFlag::Adapters::Flipper.register_feature_groups diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index e3505579204b9d5d4c95d9adfbe816979361faaa..024f9ee19d761632a2ab3281c7ff722c87b1ee44 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,13 +1,13 @@ require 'sidekiq/web' def enable_reliable_fetch? - return true unless Feature::FlipperFeature.table_exists? + return true unless Feature.table_exists? Feature.enabled?(:gitlab_sidekiq_reliable_fetcher, default_enabled: true) end def enable_semi_reliable_fetch_mode? - return true unless Feature::FlipperFeature.table_exists? + return true unless Feature.table_exists? Feature.enabled?(:gitlab_sidekiq_enable_semi_reliable_fetcher, default_enabled: true) end diff --git a/config/initializers/unleash.rb b/config/initializers/unleash.rb new file mode 100644 index 0000000000000000000000000000000000000000..f968da0aa9e691c2991ba38113e54ef598bd5035 --- /dev/null +++ b/config/initializers/unleash.rb @@ -0,0 +1,4 @@ +return unless Gitlab.config.unleash.enabled && defined?(::Unicorn) + +FeatureFlag::Adapters::Unleash.configure +UNLEASH = Unleash::Client.new diff --git a/ee/app/finders/feature_flags_finder.rb b/ee/app/finders/feature_flags_finder.rb index 60a2ffac6ded465f8e5fd15bea87908014614af4..7a02b48e70945be62c2f4a563e3bd2a21467c70d 100644 --- a/ee/app/finders/feature_flags_finder.rb +++ b/ee/app/finders/feature_flags_finder.rb @@ -10,7 +10,7 @@ def initialize(project, current_user, params = {}) @params = params end - def execute + def execute(preload: false) unless Ability.allowed?(current_user, :read_feature_flag, project) return Operations::FeatureFlag.none end @@ -19,6 +19,7 @@ def execute items = by_scope(items) items = for_list(items) + items.preload_relations if preload items.ordered end diff --git a/ee/app/models/operations/feature_flag_scope.rb b/ee/app/models/operations/feature_flag_scope.rb index b8eb9e1950da69c99e5979d066fa06f179ec5c5e..08f938c9a73d1e588266ca544d74b3152366beaa 100644 --- a/ee/app/models/operations/feature_flag_scope.rb +++ b/ee/app/models/operations/feature_flag_scope.rb @@ -34,7 +34,7 @@ def userwithid_strategy def self.with_name_and_description joins(:feature_flag) - .select(FeatureFlag.arel_table[:name], FeatureFlag.arel_table[:description]) + .select(::Operations::FeatureFlag.arel_table[:name], ::Operations::FeatureFlag.arel_table[:description]) end def self.for_unleash_client(project, environment) diff --git a/ee/lib/api/feature_flag/scopes.rb b/ee/lib/api/feature_flag/scopes.rb new file mode 100644 index 0000000000000000000000000000000000000000..e7f9108c4fc6895dbb188ad5bcdcb6e7b2df8665 --- /dev/null +++ b/ee/lib/api/feature_flag/scopes.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module API + module FeatureFlag + class Scopes < Grape::API + include PaginationParams + + FEATURE_FLAG_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS + .merge(name: API::NO_SLASH_URL_PART_REGEX) + + before { authorize_read_feature_flags! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get all effective scopes under the project' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag::DetailedScope + end + params do + requires :environment_scope, type: String, desc: 'The environment scope' + end + get ':id/feature_flag_scopes' do + present scopes_for_environment, with: EE::API::Entities::FeatureFlag::DetailedScope + end + + params do + requires :name, type: String, desc: 'The name of the feature flag' + end + resource ':id/feature_flags/:name/scopes', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMETS do + desc 'Get all scopes of a feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag::Scope + end + params do + use :pagination + end + get do + present paginate(feature_flag.scopes), with: EE::API::Entities::FeatureFlag::Scope + end + + desc 'Get a scope of a feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag::Scope + end + params do + requires :scope_id, type: String + end + get ':scope_id' do + present scope, with: EE::API::Entities::FeatureFlag::Scope + end + + desc 'Create a new scope for a feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag::Scope + end + params do + requires :environment_scope, type: String + requires :active, type: Boolean + requires :strategies, type: JSON + end + post do + authorize_update_feature_flag! + + param = { scopes_attributes: [declared_params(include_missing: false)] } + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, param) + .execute(feature_flag) + + if result[:status] == :success + present result[:feature_flag].scopes.last, with: EE::API::Entities::FeatureFlag::Scope + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Update a scope of a feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag::Scope + end + params do + requires :scope_id, type: String + optional :environment_scope, type: String + optional :active, type: Boolean + optional :strategies, type: JSON + end + put ':scope_id' do + authorize_update_feature_flag! + + param = declared_params(include_missing: false) + param[:id] = param.delete(:scope_id) + param = { scopes_attributes: [param] } + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, param) + .execute(feature_flag) + + if result[:status] == :success + present scope.reload, with: EE::API::Entities::FeatureFlag::Scope + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Delete a scope from a feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag::Scope + end + params do + optional :scope_id, type: String, desc: 'The scope' + end + delete ':scope_id' do + authorize_update_feature_flag! + + param = { scopes_attributes: [{ id: scope.id, _destroy: 1 }] } + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, param) + .execute(feature_flag) + + if result[:status] == :success + present scope, with: EE::API::Entities::FeatureFlag::Scope + else + render_api_error!(result[:message], result[:http_status]) + end + end + end + end + + helpers do + def authorize_read_feature_flags! + authorize! :read_feature_flag, user_project + end + + def authorize_update_feature_flag! + authorize! :update_feature_flag, feature_flag + end + + def feature_flag + @feature_flag ||= user_project.operations_feature_flags.find_by_name!(params[:name]) + end + + def scope + @scope ||= feature_flag.scopes.find_by_id!(params[:scope_id]) + end + + def scopes_for_environment + Operations::FeatureFlagScope.for_unleash_client(user_project, params[:environment_scope]) + end + end + end + end +end diff --git a/ee/lib/api/feature_flags.rb b/ee/lib/api/feature_flags.rb new file mode 100644 index 0000000000000000000000000000000000000000..d30198751068a4fe24d540ce056cc87024c652e7 --- /dev/null +++ b/ee/lib/api/feature_flags.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +module API + class FeatureFlags < Grape::API + include PaginationParams + + FEATURE_FLAG_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS + .merge(name: API::NO_SLASH_URL_PART_REGEX) + + before { authorize_read_feature_flags! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get all feature flags of a project' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag + end + params do + optional :scope, type: String, desc: 'The scope', values: %w[enabled disabled] + use :pagination + end + get ':id/feature_flags' do + feature_flags = ::FeatureFlagsFinder + .new(user_project, current_user, params) + .execute(preload: true) + + present paginate(feature_flags), with: EE::API::Entities::FeatureFlag + end + + desc 'Get a feature flag of a project' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag + end + params do + requires :name, type: String, desc: 'The name of the feature flag' + end + get ':id/feature_flags/:name', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMETS do + present feature_flag, with: EE::API::Entities::FeatureFlag + end + + desc 'Create a new feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag + end + params do + requires :name, type: String + optional :description, type: String + optional :scopes_attributes, type: Array do + requires :environment_scope, type: String + requires :active, type: Boolean + requires :strategies, type: JSON + end + end + post ':id/feature_flags' do + authorize_create_feature_flag! + + result = ::FeatureFlags::CreateService + .new(user_project, current_user, declared_params(include_missing: false)) + .execute + + if result[:status] == :success + present result[:feature_flag], with: EE::API::Entities::FeatureFlag + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Update a feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag + end + params do + optional :new_name, type: String + optional :description, type: String + at_least_one_of :new_name, :description + end + put ':id/feature_flags/:name', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMETS do + authorize_update_feature_flag! + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, declared_params(include_missing: false)) + .execute(feature_flag) + + if result[:status] == :success + present result[:feature_flag], with: EE::API::Entities::FeatureFlag + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Enable a feature flag for an environment with strategy' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag + end + params do + requires :name, type: String + requires :environment_scope, type: String + requires :strategy, type: JSON + end + post ':id/feature_flags/:name/enable', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMETS do + authorize_create_feature_flag! + + result = nil + + if feature_flag = user_project.operations_feature_flags.find_by_name(params[:name]) + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + + update_params = unless scope + { + scopes_attributes:[{ + active: true, + environment_scope: params[:environment_scope], + strategies: [params[:strategy]] + }] + } + else + { + scopes_attributes:[{ + id: scope.id, + active: true, + strategies: scope.strategies.push(params[:strategy]).uniq + }] + } + end + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, update_params) + .execute(feature_flag) + else + create_params = { + name: params[:name], + scopes_attributes:[{ + active: false, + environment_scope: '*' + },{ + active: true, + environment_scope: params[:environment_scope], + strategies: [params[:strategy]] + }] + } + + result = ::FeatureFlags::CreateService + .new(user_project, current_user, create_params) + .execute + end + + if result.nil? + render_api_error!('Bad request', 400) + elsif result[:status] == :success + present result[:feature_flag], with: EE::API::Entities::FeatureFlag + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Disable a feature flag for an environment with strategy' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag + end + params do + requires :name, type: String + requires :environment_scope, type: String + requires :strategy, type: JSON + end + post ':id/feature_flags/:name/disable', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMETS do + authorize_create_feature_flag! + + result = nil + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + + not_found! unless scope + + remained_strategy = scope.strategies.reject { |str| str['name'] == params[:strategy]['name'] && str['parameters'] == params[:strategy]['parameters'] } + + not_modified! if scope.strategies == remained_strategy + + update_params = if remained_strategy.empty? + { + scopes_attributes:[{ + id: scope.id, + _destroy: 1 + }] + } + else + { + scopes_attributes:[{ + id: scope.id, + strategies: remained_strategy + }] + } + end + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, update_params) + .execute(feature_flag) + + if result.nil? + render_api_error!('Bad request', 400) + elsif result[:status] == :success + present result[:feature_flag], with: EE::API::Entities::FeatureFlag + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Delete a feature flag' do + detail 'This feature was introduced in GitLab 12.4.' + success EE::API::Entities::FeatureFlag + end + params do + requires :name, type: String, desc: 'The name of the feature flag' + optional :environment_scope, type: String, desc: 'The environment scope' + end + delete ':id/feature_flags/:name', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMETS do + authorize_destroy_feature_flag! + + result = nil + + if params[:environment_scope] + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + + not_found! unless scope + + update_params = { + scopes_attributes:[{ + id: scope.id, + _destroy: 1 + }] + } + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, update_params) + .execute(feature_flag) + else + result = ::FeatureFlags::DestroyService + .new(user_project, current_user, declared_params(include_missing: false)) + .execute(feature_flag) + end + + if result.nil? + render_api_error!('Bad request', 400) + elsif result[:status] == :success + present result[:feature_flag], with: EE::API::Entities::FeatureFlag + else + render_api_error!(result[:message], result[:http_status]) + end + end + end + + helpers do + def authorize_read_feature_flags! + authorize! :read_feature_flag, user_project + end + + def authorize_read_feature_flag! + authorize! :read_feature_flag, feature_flag + end + + def authorize_create_feature_flag! + authorize! :create_feature_flag, user_project + end + + def authorize_update_feature_flag! + authorize! :update_feature_flag, feature_flag + end + + def authorize_destroy_feature_flag! + authorize! :destroy_feature_flag, feature_flag + end + + def feature_flag + @feature_flag ||= user_project.operations_feature_flags.find_by_name!(params[:name]) + end + end + end +end diff --git a/ee/lib/ee/api/api.rb b/ee/lib/ee/api/api.rb index 91563ca6ec41bde110a42fcb3d9706d20de0a853..b0a1d89cfb36f28ee113bc1e3ce2e99b6aad5037 100644 --- a/ee/lib/ee/api/api.rb +++ b/ee/lib/ee/api/api.rb @@ -17,6 +17,8 @@ module API mount ::API::EpicIssues mount ::API::EpicLinks mount ::API::Epics + mount ::API::FeatureFlags + mount ::API::FeatureFlag::Scopes mount ::API::ContainerRegistryEvent mount ::API::Geo mount ::API::GeoNodes diff --git a/ee/lib/ee/api/entities.rb b/ee/lib/ee/api/entities.rb index c3da875234ee1c05e04798adaec79ab8d020c9c8..8b2ec554d9c8f4d1706ccad8e593f0d01f916802 100644 --- a/ee/lib/ee/api/entities.rb +++ b/ee/lib/ee/api/entities.rb @@ -789,6 +789,27 @@ def can_read_vulnerabilities?(user, project) Ability.allowed?(user, :read_project_security_dashboard, project) end end + + class FeatureFlag < Grape::Entity + class Scope < Grape::Entity + expose :id + expose :active + expose :environment_scope + expose :strategies + expose :created_at + expose :updated_at + end + + class DetailedScope < Scope + expose :name + end + + expose :name + expose :description + expose :created_at + expose :updated_at + expose :scopes, using: Scope + end end end end diff --git a/ee/spec/requests/api/feature_flag/scopes_spec.rb b/ee/spec/requests/api/feature_flag/scopes_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e1f7abac826b430cc94538757e484e3baedfb00c --- /dev/null +++ b/ee/spec/requests/api/feature_flag/scopes_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe API::FeatureFlag::Scopes do + include FeatureFlagHelpers + + let(:project) { create(:project, :repository) } + let(:developer) { create(:user) } + + before do + stub_licensed_features(feature_flags: true) + + project.add_developer(developer) + end + + describe 'GET /projects/:id/feature_flags/:name/scopes' do + context 'when there are two scopes' do + let!(:feature_flag) { create_flag(project, 'test') } + let!(:additional_scope) { create_scope(feature_flag, 'production', false) } + + it 'returns scopes' do + get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(2) + expect(json_response.first['environment_scope']).to eq(feature_flag.scopes[0].environment_scope) + expect(json_response.second['environment_scope']).to eq(feature_flag.scopes[1].environment_scope) + end + end + end + + describe 'GET /projects/:id/feature_flags/:name/scopes/:scope_id' do + context 'when there is a feature flag' do + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + let(:default_scope) { feature_flag.default_scope } + + it 'returns a scope' do + get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{default_scope.id}", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(default_scope.id) + expect(json_response['active']).to eq(default_scope.active) + expect(json_response['environment_scope']).to eq(default_scope.environment_scope) + end + end + end + + describe 'POST /projects/:id/feature_flags/:name/scopes' do + let(:params) do + { + environment_scope: 'staging', + active: false, + strategies: [{ + name: 'userWithId', + parameters: { userIds: 'user:1,project:1,group:1' } + }].to_json + } + end + + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + + it 'creates a new scope' do + post api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes", developer), params: params + + expect(response).to have_gitlab_http_status(:created) + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(scope.active).to eq(params[:active]) + expect(scope.strategies).to eq(JSON.parse(params[:strategies])) + end + end + + describe 'PUT /projects/:id/feature_flags/:name/scopes/:scope_id' do + let(:params) { { active: true } } + + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + let!(:production_scope) { create_scope(feature_flag, 'production', false) } + + it 'updates the name' do + put api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{production_scope.id}", developer), params: params + + expect(response).to have_gitlab_http_status(:ok) + + production_scope.reload + expect(production_scope.active).to eq(true) + end + end + + describe 'DELETE /projects/:id/feature_flags/:name/scopes/:scope_id' do + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + let!(:production_scope) { create_scope(feature_flag, 'production', false) } + + it 'destroys the scope' do + expect do + delete api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{production_scope.id}", developer) + end.to change { Operations::FeatureFlagScope.count }.by(-1) + + expect(response).to have_gitlab_http_status(:ok) + end + end +end diff --git a/ee/spec/requests/api/feature_flags_spec.rb b/ee/spec/requests/api/feature_flags_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d72745f994942ba7ee089d33fcec9a9f439eb974 --- /dev/null +++ b/ee/spec/requests/api/feature_flags_spec.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe API::FeatureFlags do + include FeatureFlagHelpers + + let(:project) { create(:project, :repository) } + let(:developer) { create(:user) } + let(:non_project_member) { create(:user) } + + before do + stub_licensed_features(feature_flags: true) + + project.add_developer(developer) + end + + describe 'GET /projects/:id/feature_flags' do + context 'when there are two feature flags' do + let!(:feature_flag_1) do + create(:operations_feature_flag, project: project) + end + + let!(:feature_flag_2) do + create(:operations_feature_flag, project: project) + end + + it 'returns feature flags' do + get api("/projects/#{project.id}/feature_flags", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.count).to eq(2) + expect(json_response.first['name']).to eq(feature_flag_1.name) + expect(json_response.second['name']).to eq(feature_flag_2.name) + end + end + end + + describe 'GET /projects/:id/feature_flags/:name' do + context 'when there is a feature flag' do + let!(:feature_flag) do + create(:operations_feature_flag, project: project) + end + + it 'returns a feature flag entry' do + get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq(feature_flag.name) + expect(json_response['description']).to eq(feature_flag.description) + end + end + end + + describe 'POST /projects/:id/feature_flags' do + let(:params) do + { + name: 'awesome-feature', + description: 'aaaaaaaa', + scopes_attributes: [ + { + environment_scope: '*', + active: true, + strategies: [{ + name: 'default', + parameters: {} + }].to_json + }, + { + environment_scope: 'production', + active: true, + strategies: [{ + name: 'userWithId', + parameters: { + userIds: 'user:1' + } + }].to_json + }] + } + end + + it 'creates a new feature flag' do + post api("/projects/#{project.id}/feature_flags", developer), params: params + + expect(response).to have_gitlab_http_status(:created) + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.name).to eq(params[:name]) + expect(feature_flag.description).to eq(params[:description]) + + feature_flag.scopes.each_with_index do |scope, index| + expect(scope.environment_scope).to eq(params[:scopes_attributes][index][:environment_scope]) + expect(scope.active).to eq(params[:scopes_attributes][index][:active]) + expect(scope.strategies).to eq(JSON.parse(params[:scopes_attributes][index][:strategies])) + end + end + end + + describe 'PUT /projects/:id/feature_flags/:name' do + let(:params) { { description: 'bbbb' } } + + let!(:feature_flag) do + create(:operations_feature_flag, project: project, description: 'aaaaa') + end + + it 'updates the name' do + put api("/projects/#{project.id}/feature_flags/#{feature_flag.name}", developer), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(project.operations_feature_flags.last.description).to eq('bbbb') + end + end + + describe 'POST /projects/:id/feature_flags/enable' do + let(:params) do + { + name: 'awesome-feature', + environment_scope: 'production', + strategy: { name: 'userWithId', parameters: { userIds: 'Project:1' } }.to_json + } + end + + context 'when feature flag & scope do not exist yet' do + it 'creates a new feature flag and scope' do + post api("/projects/#{project.id}/feature_flags/enable", developer), params: params + + expect(response).to have_gitlab_http_status(:created) + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.name).to eq(params[:name]) + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(scope.strategies).to eq([JSON.parse(params[:strategy])]) + end + end + + context 'when feature flag exists already' do + let!(:feature_flag) { create_flag(project, params[:name]) } + + context 'when environment scope does not exist yet' do + it 'creates a new scope' do + post api("/projects/#{project.id}/feature_flags/enable", developer), params: params + + expect(response).to have_gitlab_http_status(:created) + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(scope.strategies).to eq([JSON.parse(params[:strategy])]) + end + end + + context 'when scope exists already' do + let(:defined_strategy) { { name: 'userWithId', parameters: { userIds: 'Project:2' }} } + + before do + create_scope(feature_flag, params[:environment_scope], true, [defined_strategy]) + end + + it 'adds an additional strategy param' do + post api("/projects/#{project.id}/feature_flags/enable", developer), params: params + + expect(response).to have_gitlab_http_status(:created) + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(scope.strategies).to eq([defined_strategy.deep_stringify_keys, JSON.parse(params[:strategy])]) + end + end + end + end + + describe 'POST /projects/:id/feature_flags/disable' do + let(:params) do + { + name: 'awesome-feature', + environment_scope: 'production', + strategy: { name: 'userWithId', parameters: { userIds: 'Project:1' } }.to_json + } + end + + context 'when feature flag & scope do not exist yet' do + it 'returns not modified' do + post api("/projects/#{project.id}/feature_flags/disable", developer), params: params + + expect(response).to have_gitlab_http_status(:not_modified) + end + end + + context 'when feature flag exists already' do + let!(:feature_flag) { create_flag(project, params[:name]) } + + context 'when environment scope does not exist yet' do + it 'returns not modified' do + post api("/projects/#{project.id}/feature_flags/disable", developer), params: params + + expect(response).to have_gitlab_http_status(:not_modified) + end + end + + context 'when scope exists already and can find the corresponding one' do + let(:defined_strategies) { [{ name: 'userWithId', parameters: { userIds: 'Project:1' }}, { name: 'userWithId', parameters: { userIds: 'Project:2' }}] } + + before do + create_scope(feature_flag, params[:environment_scope], true, defined_strategies) + end + + it 'removes the strategy from the scope' do + post api("/projects/#{project.id}/feature_flags/disable", developer), params: params + + expect(response).to have_gitlab_http_status(:created) + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(scope.strategies).to eq([{ name: 'userWithId', parameters: { userIds: 'Project:2' }}.deep_stringify_keys]) + end + + context 'when strategies become empty array afterward' do + let(:defined_strategies) { [{ name: 'userWithId', parameters: { userIds: 'Project:1' }}] } + + it 'deactivates the scope' do + post api("/projects/#{project.id}/feature_flags/disable", developer), params: params + + expect(response).to have_gitlab_http_status(:created) + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(scope.active).to eq(false) + end + end + end + + context 'when scope exists already but cannot find the corresponding one' do + let(:defined_strategy) { { name: 'userWithId', parameters: { userIds: 'Project:2' }} } + + before do + create_scope(feature_flag, params[:environment_scope], true, [defined_strategy]) + end + + it 'returns not modified' do + post api("/projects/#{project.id}/feature_flags/disable", developer), params: params + + expect(response).to have_gitlab_http_status(:not_modified) + end + end + end + end + + describe 'DELETE /projects/:id/feature_flags/:name' do + let!(:feature_flag) do + create(:operations_feature_flag, project: project) + end + + it 'destroys the release' do + expect do + delete api("/projects/#{project.id}/feature_flags/#{feature_flag.name}", developer) + end.to change { Operations::FeatureFlag.count }.by(-1) + + expect(response).to have_gitlab_http_status(:ok) + end + end +end diff --git a/ee/spec/support/helpers/feature_flag_helpers.rb b/ee/spec/support/helpers/feature_flag_helpers.rb index e8b7cd3e58c6c8ebaedd77e90c22e38349fcf3fd..ec1907d0adcd6b6093f83416a40a17aa7deeec57 100644 --- a/ee/spec/support/helpers/feature_flag_helpers.rb +++ b/ee/spec/support/helpers/feature_flag_helpers.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module FeatureFlagHelpers - def create_flag(project, name, active, description: nil) + def create_flag(project, name, active = true, description: nil) create(:operations_feature_flag, name: name, active: active, description: description, project: project) end diff --git a/lib/feature.rb b/lib/feature.rb index 88b0d871c3adc2b5d8d3c7fe3c0e83114e3a3d3a..c2608d5fce82ca49a9ad2f2743fb4295a95ac8ad 100644 --- a/lib/feature.rb +++ b/lib/feature.rb @@ -1,64 +1,31 @@ # frozen_string_literal: true -require 'flipper/adapters/active_record' -require 'flipper/adapters/active_support_cache_store' - class Feature prepend_if_ee('EE::Feature') # rubocop: disable Cop/InjectEnterpriseEditionModule - # Classes to override flipper table names - class FlipperFeature < Flipper::Adapters::ActiveRecord::Feature - # Using `self.table_name` won't work. ActiveRecord bug? - superclass.table_name = 'features' - - def self.feature_names - pluck(:key) - end - end - - class FlipperGate < Flipper::Adapters::ActiveRecord::Gate - superclass.table_name = 'feature_gates' - end + SUPPORTED_FEATURE_FLAG_ADAPTERS = %w[unleash flipper] class << self - delegate :group, to: :flipper + delegate :all, :get, :group, :persisted?, :table_exists?, to: :adapter - def all - flipper.features.to_a - end - - def get(key) - flipper.feature(key) - end - - def persisted_names - Gitlab::SafeRequestStore[:flipper_persisted_names] ||= - begin - # We saw on GitLab.com, this database request was called 2300 - # times/s. Let's cache it for a minute to avoid that load. - Gitlab::ThreadMemoryCache.cache_backend.fetch('flipper:persisted_names', expires_in: 1.minute) do - FlipperFeature.feature_names - end + def adapter + @adapter ||= + SUPPORTED_FEATURE_FLAG_ADAPTERS.find do |type| + adapter = get_adapter(type) + break adapter if adapter.available? end end - def persisted?(feature) - # 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. - persisted_names.include?(feature.name.to_s) - end - # use `default_enabled: true` to default the flag to being `enabled` # unless set explicitly. The default is `disabled` def enabled?(key, thing = nil, default_enabled: false) - feature = Feature.get(key) + feature = get(key) # If we're not default enabling the flag or the feature has been set, always evaluate. # `persisted?` 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` - !default_enabled || Feature.persisted?(feature) ? feature.enabled?(thing) : true + !default_enabled || persisted?(feature) ? feature.enabled?(thing) : true end def disabled?(key, thing = nil, default_enabled: false) @@ -89,50 +56,10 @@ def remove(key) feature.remove end - def flipper - if Gitlab::SafeRequestStore.active? - Gitlab::SafeRequestStore[:flipper] ||= build_flipper_instance - else - @flipper ||= build_flipper_instance - end - end - - def build_flipper_instance - Flipper.new(flipper_adapter).tap { |flip| flip.memoize = true } - end - - # This method is called from config/initializers/flipper.rb and can be used - # to register Flipper groups. - # See https://docs.gitlab.com/ee/development/feature_flags.html#feature-groups - def register_feature_groups - end - - def flipper_adapter - active_record_adapter = Flipper::Adapters::ActiveRecord.new( - feature_class: FlipperFeature, - gate_class: FlipperGate) - - # Redis L2 cache - redis_cache_adapter = - Flipper::Adapters::ActiveSupportCacheStore.new( - active_record_adapter, - l2_cache_backend, - expires_in: 1.hour) - - # Thread-local L1 cache: use a short timeout since we don't have a - # way to expire this cache all at once - Flipper::Adapters::ActiveSupportCacheStore.new( - redis_cache_adapter, - l1_cache_backend, - expires_in: 1.minute) - end - - def l1_cache_backend - Gitlab::ThreadMemoryCache.cache_backend - end - - def l2_cache_backend - Rails.cache + private + + def get_adapter(type) + "FeatureFlag::Adapters::#{type.camelize}".constantize end end diff --git a/lib/feature/gitaly.rb b/lib/feature/gitaly.rb index 81f8ba5c8c3dd0efa7d83182c691009670648ba8..5351ccffe519a1a8ba19cab6462107b6943a48bc 100644 --- a/lib/feature/gitaly.rb +++ b/lib/feature/gitaly.rb @@ -16,7 +16,7 @@ class Gitaly class << self def enabled?(feature_flag) - return false unless Feature::FlipperFeature.table_exists? + return false unless Feature.table_exists? default_on = DEFAULT_ON_FLAGS.include?(feature_flag) Feature.enabled?("gitaly_#{feature_flag}", default_enabled: default_on) diff --git a/lib/feature_flag/adapters/flipper.rb b/lib/feature_flag/adapters/flipper.rb new file mode 100644 index 0000000000000000000000000000000000000000..1f2d7fca3743b658a244a16936986fbe4cef1911 --- /dev/null +++ b/lib/feature_flag/adapters/flipper.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'flipper/adapters/active_record' +require 'flipper/adapters/active_support_cache_store' + +module FeatureFlag + module Adapters + class Flipper + # Classes to override flipper table names + class FlipperFeature < ::Flipper::Adapters::ActiveRecord::Feature + # Using `self.table_name` won't work. ActiveRecord bug? + superclass.table_name = 'features' + + def self.feature_names + pluck(:key) + end + end + + class FlipperGate < ::Flipper::Adapters::ActiveRecord::Gate + superclass.table_name = 'feature_gates' + end + + class << self + delegate :group, to: :flipper + + def available? + true + end + + def all + flipper.features.to_a + end + + def get(key) + flipper.feature(key) + end + + # This method is called from config/initializers/flipper.rb and can be used + # to register Flipper groups. + # See https://docs.gitlab.com/ee/development/feature_flags.html#feature-groups + def register_feature_groups + end + + def persisted?(feature) + # 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. + persisted_names.include?(feature.name.to_s) + end + + def table_exists? + FlipperFeature.table_exists? + end + + private + + def flipper + if Gitlab::SafeRequestStore.active? + Gitlab::SafeRequestStore[:flipper] ||= build_flipper_instance + else + @flipper ||= build_flipper_instance + end + end + + def persisted_names + Gitlab::SafeRequestStore[:flipper_persisted_names] ||= + begin + # We saw on GitLab.com, this database request was called 2300 + # times/s. Let's cache it for a minute to avoid that load. + Gitlab::ThreadMemoryCache.cache_backend.fetch('flipper:persisted_names', expires_in: 1.minute) do + FlipperFeature.feature_names + end + end + end + + def build_flipper_instance + ::Flipper.new(flipper_adapter).tap { |flip| flip.memoize = true } + end + + def flipper_adapter + active_record_adapter = ::Flipper::Adapters::ActiveRecord.new( + feature_class: FlipperFeature, + gate_class: FlipperGate) + + # Redis L2 cache + redis_cache_adapter = + ::Flipper::Adapters::ActiveSupportCacheStore.new( + active_record_adapter, + l2_cache_backend, + expires_in: 1.hour) + + # Thread-local L1 cache: use a short timeout since we don't have a + # way to expire this cache all at once + ::Flipper::Adapters::ActiveSupportCacheStore.new( + redis_cache_adapter, + l1_cache_backend, + expires_in: 1.minute) + end + + def l1_cache_backend + Gitlab::ThreadMemoryCache.cache_backend + end + + def l2_cache_backend + Rails.cache + end + end + end + end +end diff --git a/lib/feature_flag/adapters/unleash.rb b/lib/feature_flag/adapters/unleash.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d31fc3653658f2a039fbf39816e98c84f0ab16b --- /dev/null +++ b/lib/feature_flag/adapters/unleash.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module FeatureFlag + module Adapters + class Unleash + class Feature + attr_accessor :active + attr_accessor :strategies + attr_reader :name + + alias_attribute :state, :active + + Gate = Struct.new(:key, :value) + + def initialize(name) + @name = name.to_s + end + + def enabled?(thing = nil) + client.is_enabled?(name, context(thing)) + end + + def off?(thing = nil) + !enabled?(thing) + end + + def enable(thing = true) + HTTParty.post(Unleash.enable_feature_flag_url(name), + headers: Unleash.request_headers, + body: { name: name, + environment_scope: Gitlab.config.unleash.app_name, + strategy: strategy_for(thing).to_json }) + end + + def disable(thing = false) + HTTParty.post(Unleash.disable_feature_flag_url(name), + headers: Unleash.request_headers, + body: { name: name, + environment_scope: Gitlab.config.unleash.app_name, + strategy: strategy_for(thing).to_json }) + end + + def enable_group(group) + # Not Supported yet (See https://gitlab.com/gitlab-org/gitlab-ee/issues/9566) + end + + def disable_group(group) + # Not Supported yet (See https://gitlab.com/gitlab-org/gitlab-ee/issues/9566) + end + + def remove + HTTParty.post(Unleash.delete_feature_flag_url(name), + headers: Unleash.request_headers, + body: { name: name, + environment_scope: Gitlab.config.unleash.app_name }) + end + + def persisted? + toggles = ::Unleash.toggles + toggles.present? && toggles.any? { |toggle| toggle['name'] == name } + end + + def gates + @gates ||= strategies.map { |strategy| Gate.new(strategy['name'], strategy['parameters']) } + end + + def gate_values + @gate_values ||= strategies.inject({}) do |hash, strategy| + hash[strategy['name']] = strategy['parameters'].to_s + hash + end + end + + private + + def strategy_for(thing) + if thing.in?([true, false]) + { name: 'default', parameters: {} } + else + { name: 'userWithId', parameters: { userIds: sanitized(thing) } } + end + end + + def client + FeatureFlag::Adapters::Unleash.client + end + + def context(thing) + ::Unleash::Context.new(properties: { thing: sanitized(thing) }) + end + + def sanitized(thing) + thing = thing.__getobj__ if thing.respond_to?(:__getobj__) # Resolve SimpleDelegator + + return thing unless thing.is_a?(ActiveRecord::Base) + + "#{thing.class.name}:#{thing.id}" + end + end + + class << self + include Gitlab::Utils::StrongMemoize + + def available? + Gitlab.config.unleash.enabled + end + + def all + response = HTTParty.get(get_feature_flag_scopes_url, + headers: request_headers, + query: { environment_scope: Gitlab.config.unleash.app_name } + ) + + response.map do |scope| + feature = Feature.new(scope['name']) + feature.active = scope['active'] + feature.strategies = scope['strategies'] + feature + end + end + + def get(key) + Feature.new(key) + end + + def persisted?(feature) + feature.persisted? + end + + def table_exists? + true + end + + def configure + ::Unleash.configure do |config| + config.url = Gitlab.config.unleash.url + config.app_name = Gitlab.config.unleash.app_name + config.instance_id = Gitlab.config.unleash.instance_id + config.logger = Gitlab::Unleash::Logger + end + end + + def get_feature_flag_scopes_url + "#{api_endpoint}/feature_flag_scopes" + end + + def enable_feature_flag_url(key) + "#{api_endpoint}/feature_flags/#{key}/enable" + end + + def disable_feature_flag_url(key) + "#{api_endpoint}/feature_flags/#{key}/disable" + end + + def delete_feature_flag_url(key) + "#{api_endpoint}/feature_flags/#{key}" + end + + def api_endpoint + strong_memoize(:api_endpoint) do + api_url, project_id = Gitlab.config.unleash.url + .scan( %r{(https?://.*/api/v4)/feature_flags/unleash/(\d+)} ) + .first + + "#{api_url}/projects/#{project_id}/" + end + end + + def request_headers + strong_memoize(:request_headers) do + { 'Private-Token': Gitlab.config.unleash.personal_access_token } + end + end + + def client + # TODO: Fix + @client ||= if defined?(UNLEASH) + UNLEASH + elsif defined?(Rails.configuration.unleash) + Rails.configuration.unleash + else + ::Unleash::Client.new( + url: Gitlab.config.unleash.url, + app_name: Gitlab.config.unleash.app_name, + instance_id: Gitlab.config.unleash.instance_id, + logger: Gitlab::Unleash::Logger, + disable_metrics: true) + end + end + end + end + end +end diff --git a/lib/gitlab/unleash/logger.rb b/lib/gitlab/unleash/logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..5de41d9abde4ee059fdbc1993e947b4088c31ba3 --- /dev/null +++ b/lib/gitlab/unleash/logger.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Gitlab + module Unleash + class Logger < ::Gitlab::JsonLogger + def self.file_name_noext + 'unleash' + end + + def self.level=(level) + # no-op, otherwise `Unleash.logger.level` causes NotImplementedError. + end + + def self.build + super.tap { |logger| logger.level = Rails.logger.level } # rubocop:disable Gitlab/RailsLogger + end + end + end +end diff --git a/lib/unleash/strategy/user_with_id.rb b/lib/unleash/strategy/user_with_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..c9790f58e2d7989f1dce90dc6e54445d7c5b761a --- /dev/null +++ b/lib/unleash/strategy/user_with_id.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +## +# We override the existing userWithId strategy to make it compatible with ActiveRecord +# objects such as Project, Group, User, etc. +# +# To properly gate a feature per target, you have to define userWithId strategy +# params in the following convention. +# +# ":" +# +# For example, if you want to enable a featue on the project (id: 123), you need +# to define the value "Project:123". +module Unleash + module Strategy + class UserWithId < Base + def name + 'userWithId' + end + + # requires: params['userIds'], context.user_id, + def is_enabled?(params = {}, context = nil) + return false unless params.is_a?(Hash) && params.has_key?(PARAM) + return false unless params.fetch(PARAM, nil).is_a? String + return false unless context.class.name == 'Unleash::Context' + + target = context.properties[:thing] + + params[PARAM].split(",").map(&:strip).any? { |allowed| allowed == target } + end + end + end +end