Commit 12147d80 authored by Mark Lapierre's avatar Mark Lapierre

Merge branch 'ml-allow-qa-tests-to-toggle-feature-flags-per-project-group-user' into 'master'

Allow scoped feature flag toggling in QA tests

See merge request gitlab-org/gitlab!42923
parents 13f67f91 feaf0837
# Testing with feature flags # Testing with feature flags
To run a specific test with a feature flag enabled you can use the `QA::Runtime::Feature` class to enable and disable feature flags ([via the API](../../../api/features.md)). To run a specific test with a feature flag enabled you can use the `QA::Runtime::Feature` class to
enable and disable feature flags ([via the API](../../../api/features.md)).
Note that administrator authorization is required to change feature flags. `QA::Runtime::Feature` will automatically authenticate as an administrator as long as you provide an appropriate access token via `GITLAB_QA_ADMIN_ACCESS_TOKEN` (recommended), or provide `GITLAB_ADMIN_USERNAME` and `GITLAB_ADMIN_PASSWORD`. Note that administrator authorization is required to change feature flags. `QA::Runtime::Feature`
will automatically authenticate as an administrator as long as you provide an appropriate access
token via `GITLAB_QA_ADMIN_ACCESS_TOKEN` (recommended), or provide `GITLAB_ADMIN_USERNAME`
and `GITLAB_ADMIN_PASSWORD`.
Please be sure to include the tag `:requires_admin` so that the test can be skipped in environments where admin access is not available. Please be sure to include the tag `:requires_admin` so that the test can be skipped in environments
where admin access is not available.
CAUTION: **Caution:**
You are strongly advised to [enable feature flags only for a group, project, user](../../feature_flags/development.md#feature-actors),
or [feature group](../../feature_flags/development.md#feature-groups). This makes it possible to
test a feature in a shared environment without affecting other users.
For example, the code below would enable a feature flag named `:feature_flag_name` for the project
created by the test:
```ruby ```ruby
RSpec.describe "with feature flag enabled", :requires_admin do RSpec.describe "with feature flag enabled", :requires_admin do
let(:project) { Resource::Project.fabricate_via_api! }
before do before do
Runtime::Feature.enable('feature_flag_name') Runtime::Feature.enable(:feature_flag_name, project: project)
end end
it "feature flag test" do it "feature flag test" do
# Execute a test with a feature flag enabled # Execute the test with the feature flag enabled.
# It will only affect the project created in this test.
end end
after do after do
Runtime::Feature.disable('feature_flag_name') Runtime::Feature.disable(:feature_flag_name, project: project)
end end
end end
``` ```
Note that the `enable` and `disable` methods first set the flag and then check that the updated
value is returned by the API.
Similarly, you can enable a feature for a group, user, or feature group:
```ruby
group = Resource::Group.fabricate_via_api!
Runtime::Feature.enable(:feature_flag_name, group: group)
user = Resource::User.fabricate_via_api!
Runtime::Feature.enable(:feature_flag_name, user: user)
feature_group = "a_feature_group"
Runtime::Feature.enable(:feature_flag_name, feature_group: feature_group)
```
If no scope is provided, the feature flag will be set instance-wide:
```ruby
# This will affect all users!
Runtime::Feature.enable(:feature_flag_name)
```
## Running a scenario with a feature flag enabled ## Running a scenario with a feature flag enabled
It's also possible to run an entire scenario with a feature flag enabled, without having to edit existing tests or write new ones. It's also possible to run an entire scenario with a feature flag enabled, without having to edit
existing tests or write new ones.
Please see the [QA README](https://gitlab.com/gitlab-org/gitlab/tree/master/qa#running-tests-with-a-feature-flag-enabled) for details. Please see the [QA README](https://gitlab.com/gitlab-org/gitlab/tree/master/qa#running-tests-with-a-feature-flag-enabled)
for details.
# frozen_string_literal: true # frozen_string_literal: true
require 'active_support/core_ext/object/blank'
module QA module QA
module Runtime module Runtime
module Feature class Feature
extend self class << self
extend Support::Api # Documentation: https://docs.gitlab.com/ee/api/features.html
SetFeatureError = Class.new(RuntimeError) include Support::Api
AuthorizationError = Class.new(RuntimeError)
def enable(key) SetFeatureError = Class.new(RuntimeError)
QA::Runtime::Logger.info("Enabling feature: #{key}") AuthorizationError = Class.new(RuntimeError)
set_feature(key, true) UnknownScopeError = Class.new(RuntimeError)
end
def disable(key) def remove(key)
QA::Runtime::Logger.info("Disabling feature: #{key}") request = Runtime::API::Request.new(api_client, "/features/#{key}")
set_feature(key, false) response = delete(request.url)
end unless response.code == QA::Support::Api::HTTP_STATUS_NO_CONTENT
raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`."
def remove(key) end
request = Runtime::API::Request.new(api_client, "/features/#{key}")
response = delete(request.url)
unless response.code == QA::Support::Api::HTTP_STATUS_NO_CONTENT
raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`."
end end
end
def enable_and_verify(key)
set_and_verify(key, enable: true)
end
def disable_and_verify(key) def enable(key, **scopes)
set_and_verify(key, enable: false) set_and_verify(key, enable: true, **scopes)
end end
def enabled?(key) def disable(key, **scopes)
feature = JSON.parse(get_features).find { |flag| flag["name"] == key } set_and_verify(key, enable: false, **scopes)
feature && feature["state"] == "on" end
end
def get_features def enabled?(key, **scopes)
request = Runtime::API::Request.new(api_client, "/features") feature = JSON.parse(get_features).find { |flag| flag['name'] == key.to_s }
response = get(request.url) feature && feature['state'] == 'on' || feature['state'] == 'conditional' && scopes.present? && enabled_scope?(feature['gates'], scopes)
response.body end
end
private private
def api_client def api_client
@api_client ||= begin @api_client ||= Runtime::API::Client.as_admin
if Runtime::Env.admin_personal_access_token rescue Runtime::API::Client::AuthorizationError => e
Runtime::API::Client.new(:gitlab, personal_access_token: Runtime::Env.admin_personal_access_token) raise AuthorizationError, "Administrator access is required to enable/disable feature flags. #{e.message}"
else end
user = Resource::User.fabricate_via_api! do |user|
user.username = Runtime::User.admin_username
user.password = Runtime::User.admin_password
end
unless user.admin? def enabled_scope?(gates, scopes)
raise AuthorizationError, "Administrator access is required to enable/disable feature flags. User '#{user.username}' is not an administrator." scopes.each do |key, value|
case key
when :project, :group, :user
actors = gates.filter { |i| i['key'] == 'actors' }.first['value']
break actors.include?("#{key.to_s.capitalize}:#{value.id}")
when :feature_group
groups = gates.filter { |i| i['key'] == 'groups' }.first['value']
break groups.include?(value)
else
raise UnknownScopeError, "Unknown scope: #{key}"
end end
Runtime::API::Client.new(:gitlab, user: user)
end end
end end
end
# Change a feature flag and verify that the change was successful def get_features
# Arguments: request = Runtime::API::Request.new(api_client, '/features')
# key: The feature flag to set (as a string) response = get(request.url)
# enable: `true` to enable the flag, `false` to disable it response.body
def set_and_verify(key, enable:) end
Support::Retrier.retry_on_exception(sleep_interval: 2) do
enable ? enable(key) : disable(key)
is_enabled = nil # Change a feature flag and verify that the change was successful
# Arguments:
# key: The feature flag to set (as a string)
# enable: `true` to enable the flag, `false` to disable it
# scopes: Any scope (user, project, group) to restrict the change to
def set_and_verify(key, enable:, **scopes)
msg = "#{enable ? 'En' : 'Dis'}abling feature: #{key}"
msg += " for scope \"#{scopes_to_s(scopes)}\"" if scopes.present?
QA::Runtime::Logger.info(msg)
QA::Support::Waiter.wait_until(sleep_interval: 1) do Support::Retrier.retry_on_exception(sleep_interval: 2) do
is_enabled = enabled?(key) set_feature(key, enable, scopes)
is_enabled == enable
end is_enabled = nil
QA::Support::Waiter.wait_until(sleep_interval: 1) do
is_enabled = enabled?(key, scopes)
is_enabled == enable || !enable && scopes.present?
end
if is_enabled == enable
QA::Runtime::Logger.info("Successfully #{enable ? 'en' : 'dis'}abled and verified feature flag: #{key}")
else
raise SetFeatureError, "#{key} was not #{enable ? 'en' : 'dis'}abled!" if enable
raise SetFeatureError, "#{key} was not #{enable ? 'enabled' : 'disabled'}!" unless is_enabled == enable QA::Runtime::Logger.warn("Feature flag scope was removed but the flag is still enabled globally.")
end
end
end
QA::Runtime::Logger.info("Successfully #{enable ? 'enabled' : 'disabled'} and verified feature flag: #{key}") def set_feature(key, value, **scopes)
scopes[:project] = scopes[:project].full_path if scopes.key?(:project)
scopes[:group] = scopes[:group].full_path if scopes.key?(:group)
scopes[:user] = scopes[:user].username if scopes.key?(:user)
request = Runtime::API::Request.new(api_client, "/features/#{key}")
response = post(request.url, scopes.merge({ value: value }))
unless response.code == QA::Support::Api::HTTP_STATUS_CREATED
raise SetFeatureError, "Setting feature flag #{key} to #{value} failed with `#{response}`."
end
end end
end
def set_feature(key, value) def scopes_to_s(**scopes)
request = Runtime::API::Request.new(api_client, "/features/#{key}") key = scopes.each_key.first
response = post(request.url, { value: value }) s = "#{key}: "
unless response.code == QA::Support::Api::HTTP_STATUS_CREATED case key
raise SetFeatureError, "Setting feature flag #{key} to #{value} failed with `#{response}`." when :project, :group
s += scopes[key].full_path
when :user
s += scopes[key].username
when :feature_group
s += scopes[key]
else
raise UnknownScopeError, "Unknown scope: #{key}"
end
s
end end
end end
end end
......
...@@ -17,12 +17,12 @@ module QA ...@@ -17,12 +17,12 @@ module QA
end end
before do before do
Runtime::Feature.enable_and_verify('gitaly_distributed_reads') Runtime::Feature.enable(:gitaly_distributed_reads)
praefect_manager.wait_for_replication(project.id) praefect_manager.wait_for_replication(project.id)
end end
after do after do
Runtime::Feature.disable_and_verify('gitaly_distributed_reads') Runtime::Feature.disable(:gitaly_distributed_reads)
end end
it 'reads from each node', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/979' do it 'reads from each node', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/979' do
......
...@@ -4,7 +4,7 @@ module QA ...@@ -4,7 +4,7 @@ module QA
RSpec.describe 'Create' do RSpec.describe 'Create' do
describe 'Push mirror a repository over HTTP' do describe 'Push mirror a repository over HTTP' do
it 'configures and syncs LFS objects for a (push) mirrored repository', :requires_admin, testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/414' do it 'configures and syncs LFS objects for a (push) mirrored repository', :requires_admin, testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/414' do
Runtime::Feature.enable_and_verify('push_mirror_syncs_lfs') Runtime::Feature.enable(:push_mirror_syncs_lfs)
Runtime::Browser.visit(:gitlab, Page::Main::Login) Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.perform(&:sign_in_using_credentials) Page::Main::Login.perform(&:sign_in_using_credentials)
......
...@@ -4,11 +4,11 @@ module QA ...@@ -4,11 +4,11 @@ module QA
RSpec.describe 'Create', :requires_admin do RSpec.describe 'Create', :requires_admin do
describe 'Multiple file snippet' do describe 'Multiple file snippet' do
before do before do
Runtime::Feature.enable_and_verify('snippet_multiple_files') Runtime::Feature.enable('snippet_multiple_files')
end end
after do after do
Runtime::Feature.disable_and_verify('snippet_multiple_files') Runtime::Feature.disable('snippet_multiple_files')
end end
it 'creates a personal snippet with multiple files', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/842' do it 'creates a personal snippet with multiple files', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/842' do
......
...@@ -51,9 +51,7 @@ module QA ...@@ -51,9 +51,7 @@ module QA
after do after do
page.visit Runtime::Scenario.gitlab_address page.visit Runtime::Scenario.gitlab_address
%w[group_administration_nav_item].each do |flag| Runtime::Feature.remove(:group_administration_nav_item)
Runtime::Feature.remove(flag)
end
@group.remove_via_api! @group.remove_via_api!
...@@ -64,9 +62,7 @@ module QA ...@@ -64,9 +62,7 @@ module QA
end end
def setup_and_enable_enforce_sso def setup_and_enable_enforce_sso
%w[group_administration_nav_item].each do |flag| Runtime::Feature.enable(:group_administration_nav_item)
Runtime::Feature.enable_and_verify(flag)
end
page.visit Runtime::Scenario.gitlab_address page.visit Runtime::Scenario.gitlab_address
Page::Main::Login.perform(&:sign_in_using_credentials) unless Page::Main::Menu.perform(&:signed_in?) Page::Main::Login.perform(&:sign_in_using_credentials) unless Page::Main::Menu.perform(&:signed_in?)
......
...@@ -85,7 +85,7 @@ module QA ...@@ -85,7 +85,7 @@ module QA
after(:all) do after(:all) do
page.visit Runtime::Scenario.gitlab_address page.visit Runtime::Scenario.gitlab_address
%w[group_managed_accounts sign_up_on_sso group_scim group_administration_nav_item].each do |flag| [:group_managed_accounts, :sign_up_on_sso, :group_scim, :group_administration_nav_item].each do |flag|
Runtime::Feature.remove(flag) Runtime::Feature.remove(flag)
end end
...@@ -119,8 +119,8 @@ module QA ...@@ -119,8 +119,8 @@ module QA
end end
def setup_and_enable_group_managed_accounts def setup_and_enable_group_managed_accounts
%w[group_managed_accounts sign_up_on_sso group_scim group_administration_nav_item].each do |flag| [:group_managed_accounts, :sign_up_on_sso, :group_scim, :group_administration_nav_item].each do |flag|
Runtime::Feature.enable_and_verify(flag) Runtime::Feature.enable(flag)
end end
Support::Retrier.retry_on_exception do Support::Retrier.retry_on_exception do
......
...@@ -10,7 +10,7 @@ module QA ...@@ -10,7 +10,7 @@ module QA
sandbox_group.path = "saml_sso_group_#{SecureRandom.hex(8)}" sandbox_group.path = "saml_sso_group_#{SecureRandom.hex(8)}"
end end
Runtime::Feature.enable_and_verify('group_administration_nav_item') Runtime::Feature.enable(:group_administration_nav_item)
@saml_idp_service = Flow::Saml.run_saml_idp_service(@group.path) @saml_idp_service = Flow::Saml.run_saml_idp_service(@group.path)
end end
...@@ -96,7 +96,7 @@ module QA ...@@ -96,7 +96,7 @@ module QA
after(:all) do after(:all) do
@group.remove_via_api! @group.remove_via_api!
Runtime::Feature.remove('group_administration_nav_item') Runtime::Feature.remove(:group_administration_nav_item)
page.visit Runtime::Scenario.gitlab_address page.visit Runtime::Scenario.gitlab_address
Page::Main::Menu.perform(&:sign_out_if_signed_in) Page::Main::Menu.perform(&:sign_out_if_signed_in)
......
This diff is collapsed.
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