Merge branch 'feature-flags-flipper' into 'master'
Add feature toggles through Flipper See merge request !11747
This commit is contained in:
commit
3fc4b2c860
17 changed files with 483 additions and 5 deletions
4
Gemfile
4
Gemfile
|
@ -370,3 +370,7 @@ gem 'sys-filesystem', '~> 1.1.6'
|
|||
gem 'gitaly', '~> 0.7.0'
|
||||
|
||||
gem 'toml-rb', '~> 0.3.15', require: false
|
||||
|
||||
# Feature toggles
|
||||
gem 'flipper', '~> 0.10.2'
|
||||
gem 'flipper-active_record', '~> 0.10.2'
|
||||
|
|
|
@ -206,6 +206,10 @@ GEM
|
|||
path_expander (~> 1.0)
|
||||
ruby_parser (~> 3.0)
|
||||
sexp_processor (~> 4.0)
|
||||
flipper (0.10.2)
|
||||
flipper-active_record (0.10.2)
|
||||
activerecord (>= 3.2, < 6)
|
||||
flipper (~> 0.10.2)
|
||||
flowdock (0.7.1)
|
||||
httparty (~> 0.7)
|
||||
multi_json
|
||||
|
@ -907,6 +911,8 @@ DEPENDENCIES
|
|||
faraday (~> 0.11.0)
|
||||
ffaker (~> 2.4)
|
||||
flay (~> 2.8.0)
|
||||
flipper (~> 0.10.2)
|
||||
flipper-active_record (~> 0.10.2)
|
||||
fog-aws (~> 0.9)
|
||||
fog-core (~> 1.44)
|
||||
fog-google (~> 0.5)
|
||||
|
|
4
changelogs/unreleased/feature-flags-flipper.yml
Normal file
4
changelogs/unreleased/feature-flags-flipper.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Add feature toggles and API endpoints for admins
|
||||
merge_request: 11747
|
||||
author:
|
26
db/migrate/20170525174156_create_feature_tables.rb
Normal file
26
db/migrate/20170525174156_create_feature_tables.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
class CreateFeatureTables < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def self.up
|
||||
create_table :features do |t|
|
||||
t.string :key, null: false
|
||||
t.timestamps null: false
|
||||
end
|
||||
add_index :features, :key, unique: true
|
||||
|
||||
create_table :feature_gates do |t|
|
||||
t.string :feature_key, null: false
|
||||
t.string :key, null: false
|
||||
t.string :value
|
||||
t.timestamps null: false
|
||||
end
|
||||
add_index :feature_gates, [:feature_key, :key, :value], unique: true
|
||||
end
|
||||
|
||||
def self.down
|
||||
drop_table :feature_gates
|
||||
drop_table :features
|
||||
end
|
||||
end
|
20
db/schema.rb
20
db/schema.rb
|
@ -11,7 +11,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20170523091700) do
|
||||
ActiveRecord::Schema.define(version: 20170525174156) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -440,6 +440,24 @@ ActiveRecord::Schema.define(version: 20170523091700) do
|
|||
add_index "events", ["target_id"], name: "index_events_on_target_id", using: :btree
|
||||
add_index "events", ["target_type"], name: "index_events_on_target_type", using: :btree
|
||||
|
||||
create_table "feature_gates", force: :cascade do |t|
|
||||
t.string "feature_key", null: false
|
||||
t.string "key", null: false
|
||||
t.string "value"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
add_index "feature_gates", ["feature_key", "key", "value"], name: "index_feature_gates_on_feature_key_and_key_and_value", unique: true, using: :btree
|
||||
|
||||
create_table "features", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
add_index "features", ["key"], name: "index_features_on_key", unique: true, using: :btree
|
||||
|
||||
create_table "forked_project_links", force: :cascade do |t|
|
||||
t.integer "forked_to_project_id", null: false
|
||||
t.integer "forked_from_project_id", null: false
|
||||
|
|
83
doc/api/features.md
Normal file
83
doc/api/features.md
Normal file
|
@ -0,0 +1,83 @@
|
|||
# Features API
|
||||
|
||||
All methods require administrator authorization.
|
||||
|
||||
Notice that currently the API only supports boolean and percentage-of-time gate
|
||||
values.
|
||||
|
||||
## List all features
|
||||
|
||||
Get a list of all persisted features, with its gate values.
|
||||
|
||||
```
|
||||
GET /features
|
||||
```
|
||||
|
||||
```bash
|
||||
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "experimental_feature",
|
||||
"state": "off",
|
||||
"gates": [
|
||||
{
|
||||
"key": "boolean",
|
||||
"value": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "new_library",
|
||||
"state": "on",
|
||||
"gates": [
|
||||
{
|
||||
"key": "boolean",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Set or create a feature
|
||||
|
||||
Set a feature's gate value. If a feature with the given name doesn't exist yet
|
||||
it will be created. The value can be a boolean, or an integer to indicate
|
||||
percentage of time.
|
||||
|
||||
```
|
||||
POST /features/:name
|
||||
```
|
||||
|
||||
| Attribute | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `name` | string | yes | Name of the feature to create or update |
|
||||
| `value` | integer/string | yes | `true` or `false` to enable/disable, or an integer for percentage of time |
|
||||
|
||||
```bash
|
||||
curl --data "value=30" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/new_library
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "new_library",
|
||||
"state": "conditional",
|
||||
"gates": [
|
||||
{
|
||||
"key": "boolean",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"key": "percentage_of_time",
|
||||
"value": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
|
@ -42,6 +42,7 @@
|
|||
- [Sidekiq debugging](sidekiq_debugging.md)
|
||||
- [Object state models](object_state_models.md)
|
||||
- [Building a package for testing purposes](build_test_package.md)
|
||||
- [Manage feature flags](feature_flags.md)
|
||||
|
||||
## Databases
|
||||
|
||||
|
|
7
doc/development/feature_flags.md
Normal file
7
doc/development/feature_flags.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Manage feature flags
|
||||
|
||||
Starting from GitLab 9.3 we support feature flags via
|
||||
[Flipper](https://github.com/jnunemaker/flipper/). You should use the `Feature`
|
||||
class (defined in `lib/feature.rb`) in your code to get, set and list feature
|
||||
flags. During runtime you can set the values for the gates via the
|
||||
[admin API](../api/features.md).
|
|
@ -94,6 +94,7 @@ module API
|
|||
mount ::API::DeployKeys
|
||||
mount ::API::Deployments
|
||||
mount ::API::Environments
|
||||
mount ::API::Features
|
||||
mount ::API::Files
|
||||
mount ::API::Groups
|
||||
mount ::API::Internal
|
||||
|
|
|
@ -753,6 +753,28 @@ module API
|
|||
expose :impersonation
|
||||
end
|
||||
|
||||
class FeatureGate < Grape::Entity
|
||||
expose :key
|
||||
expose :value
|
||||
end
|
||||
|
||||
class Feature < Grape::Entity
|
||||
expose :name
|
||||
expose :state
|
||||
expose :gates, using: FeatureGate do |model|
|
||||
model.gates.map do |gate|
|
||||
value = model.gate_values[gate.key]
|
||||
|
||||
# By default all gate values are populated. Only show relevant ones.
|
||||
if (value.is_a?(Integer) && value.zero?) || (value.is_a?(Set) && value.empty?)
|
||||
next
|
||||
end
|
||||
|
||||
{ key: gate.key, value: value }
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
||||
module JobRequest
|
||||
class JobInfo < Grape::Entity
|
||||
expose :name, :stage
|
||||
|
|
36
lib/api/features.rb
Normal file
36
lib/api/features.rb
Normal file
|
@ -0,0 +1,36 @@
|
|||
module API
|
||||
class Features < Grape::API
|
||||
before { authenticated_as_admin! }
|
||||
|
||||
resource :features do
|
||||
desc 'Get a list of all features' do
|
||||
success Entities::Feature
|
||||
end
|
||||
get do
|
||||
features = Feature.all
|
||||
|
||||
present features, with: Entities::Feature, current_user: current_user
|
||||
end
|
||||
|
||||
desc 'Set the gate value for the given feature' do
|
||||
success Entities::Feature
|
||||
end
|
||||
params do
|
||||
requires :value, type: String, desc: '`true` or `false` to enable/disable, an integer for percentage of time'
|
||||
end
|
||||
post ':name' do
|
||||
feature = Feature.get(params[:name])
|
||||
|
||||
if %w(0 false).include?(params[:value])
|
||||
feature.disable
|
||||
elsif params[:value] == 'true'
|
||||
feature.enable
|
||||
else
|
||||
feature.enable_percentage_of_time(params[:value].to_i)
|
||||
end
|
||||
|
||||
present feature, with: Entities::Feature, current_user: current_user
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
41
lib/feature.rb
Normal file
41
lib/feature.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
require 'flipper/adapters/active_record'
|
||||
|
||||
class Feature
|
||||
# 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'
|
||||
end
|
||||
|
||||
class FlipperGate < Flipper::Adapters::ActiveRecord::Gate
|
||||
superclass.table_name = 'feature_gates'
|
||||
end
|
||||
|
||||
class << self
|
||||
def all
|
||||
flipper.features.to_a
|
||||
end
|
||||
|
||||
def get(key)
|
||||
flipper.feature(key)
|
||||
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.
|
||||
all.map(&:name).include?(feature.name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def flipper
|
||||
@flipper ||= begin
|
||||
adapter = Flipper::Adapters::ActiveRecord.new(
|
||||
feature_class: FlipperFeature, gate_class: FlipperGate)
|
||||
|
||||
Flipper.new(adapter)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,6 +2,12 @@ require 'gitaly'
|
|||
|
||||
module Gitlab
|
||||
module GitalyClient
|
||||
module MigrationStatus
|
||||
DISABLED = 1
|
||||
OPT_IN = 2
|
||||
OPT_OUT = 3
|
||||
end
|
||||
|
||||
SERVER_VERSION_FILE = 'GITALY_SERVER_VERSION'.freeze
|
||||
|
||||
MUTEX = Mutex.new
|
||||
|
@ -46,8 +52,20 @@ module Gitlab
|
|||
Gitlab.config.gitaly.enabled
|
||||
end
|
||||
|
||||
def self.feature_enabled?(feature)
|
||||
enabled? && ENV["GITALY_#{feature.upcase}"] == '1'
|
||||
def self.feature_enabled?(feature, status: MigrationStatus::OPT_IN)
|
||||
return false if !enabled? || status == MigrationStatus::DISABLED
|
||||
|
||||
feature = Feature.get("gitaly_#{feature}")
|
||||
|
||||
# If the feature hasn't been set, turn it on if it's opt-out
|
||||
return status == MigrationStatus::OPT_OUT unless Feature.persisted?(feature)
|
||||
|
||||
if feature.percentage_of_time_value > 0
|
||||
# Probabilistically enable this feature
|
||||
return Random.rand() * 100 < feature.percentage_of_time_value
|
||||
end
|
||||
|
||||
feature.enabled?
|
||||
end
|
||||
|
||||
def self.migrate(feature)
|
||||
|
|
26
spec/lib/feature_spec.rb
Normal file
26
spec/lib/feature_spec.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Feature, lib: true do
|
||||
describe '.get' do
|
||||
let(:feature) { double(:feature) }
|
||||
let(:key) { 'my_feature' }
|
||||
|
||||
it 'returns the Flipper feature' do
|
||||
expect_any_instance_of(Flipper::DSL).to receive(:feature).with(key).
|
||||
and_return(feature)
|
||||
|
||||
expect(described_class.get(key)).to be(feature)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.all' do
|
||||
let(:features) { Set.new }
|
||||
|
||||
it 'returns the Flipper features as an array' do
|
||||
expect_any_instance_of(Flipper::DSL).to receive(:features).
|
||||
and_return(features)
|
||||
|
||||
expect(described_class.all).to eq(features.to_a)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,7 +1,10 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::GitalyClient, lib: true do
|
||||
# We stub Gitaly in `spec/support/gitaly.rb` for other tests. We don't want
|
||||
# those stubs while testing the GitalyClient itself.
|
||||
describe Gitlab::GitalyClient, lib: true, skip_gitaly_mock: true do
|
||||
describe '.stub' do
|
||||
# Notice that this is referring to gRPC "stubs", not rspec stubs
|
||||
before { described_class.clear_stubs! }
|
||||
|
||||
context 'when passed a UNIX socket address' do
|
||||
|
@ -32,4 +35,81 @@ describe Gitlab::GitalyClient, lib: true do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'feature_enabled?' do
|
||||
let(:feature_name) { 'my_feature' }
|
||||
let(:real_feature_name) { "gitaly_#{feature_name}" }
|
||||
|
||||
context 'when Gitaly is disabled' do
|
||||
before { allow(described_class).to receive(:enabled?).and_return(false) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.feature_enabled?(feature_name)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the feature status is DISABLED' do
|
||||
let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::DISABLED }
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the feature_status is OPT_IN' do
|
||||
let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_IN }
|
||||
|
||||
context "when the feature flag hasn't been set" do
|
||||
it 'returns false' do
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the feature flag is set to disable" do
|
||||
before { Feature.get(real_feature_name).disable }
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the feature flag is set to enable" do
|
||||
before { Feature.get(real_feature_name).enable }
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the feature flag is set to a percentage of time" do
|
||||
before { Feature.get(real_feature_name).enable_percentage_of_time(70) }
|
||||
|
||||
it 'bases the result on pseudo-random numbers' do
|
||||
expect(Random).to receive(:rand).and_return(0.3)
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true)
|
||||
|
||||
expect(Random).to receive(:rand).and_return(0.8)
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the feature_status is OPT_OUT' do
|
||||
let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_OUT }
|
||||
|
||||
context "when the feature flag hasn't been set" do
|
||||
it 'returns true' do
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the feature flag is set to disable" do
|
||||
before { Feature.get(real_feature_name).disable }
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
104
spec/requests/api/features_spec.rb
Normal file
104
spec/requests/api/features_spec.rb
Normal file
|
@ -0,0 +1,104 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe API::Features do
|
||||
let(:user) { create(:user) }
|
||||
let(:admin) { create(:admin) }
|
||||
|
||||
describe 'GET /features' do
|
||||
let(:expected_features) do
|
||||
[
|
||||
{
|
||||
'name' => 'feature_1',
|
||||
'state' => 'on',
|
||||
'gates' => [{ 'key' => 'boolean', 'value' => true }]
|
||||
},
|
||||
{
|
||||
'name' => 'feature_2',
|
||||
'state' => 'off',
|
||||
'gates' => [{ 'key' => 'boolean', 'value' => false }]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
Feature.get('feature_1').enable
|
||||
Feature.get('feature_2').disable
|
||||
end
|
||||
|
||||
it 'returns a 401 for anonymous users' do
|
||||
get api('/features')
|
||||
|
||||
expect(response).to have_http_status(401)
|
||||
end
|
||||
|
||||
it 'returns a 403 for users' do
|
||||
get api('/features', user)
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
|
||||
it 'returns the feature list for admins' do
|
||||
get api('/features', admin)
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(json_response).to match_array(expected_features)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /feature' do
|
||||
let(:feature_name) { 'my_feature' }
|
||||
it 'returns a 401 for anonymous users' do
|
||||
post api("/features/#{feature_name}")
|
||||
|
||||
expect(response).to have_http_status(401)
|
||||
end
|
||||
|
||||
it 'returns a 403 for users' do
|
||||
post api("/features/#{feature_name}", user)
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
|
||||
it 'creates an enabled feature if passed true' do
|
||||
post api("/features/#{feature_name}", admin), value: 'true'
|
||||
|
||||
expect(response).to have_http_status(201)
|
||||
expect(Feature.get(feature_name)).to be_enabled
|
||||
end
|
||||
|
||||
it 'creates a feature with the given percentage if passed an integer' do
|
||||
post api("/features/#{feature_name}", admin), value: '50'
|
||||
|
||||
expect(response).to have_http_status(201)
|
||||
expect(Feature.get(feature_name).percentage_of_time_value).to be(50)
|
||||
end
|
||||
|
||||
context 'when the feature exists' do
|
||||
let(:feature) { Feature.get(feature_name) }
|
||||
|
||||
before do
|
||||
feature.disable # This also persists the feature on the DB
|
||||
end
|
||||
|
||||
it 'enables the feature if passed true' do
|
||||
post api("/features/#{feature_name}", admin), value: 'true'
|
||||
|
||||
expect(response).to have_http_status(201)
|
||||
expect(feature).to be_enabled
|
||||
end
|
||||
|
||||
context 'with a pre-existing percentage value' do
|
||||
before do
|
||||
feature.enable_percentage_of_time(50)
|
||||
end
|
||||
|
||||
it 'updates the percentage of time if passed an integer' do
|
||||
post api("/features/#{feature_name}", admin), value: '30'
|
||||
|
||||
expect(response).to have_http_status(201)
|
||||
expect(Feature.get(feature_name).percentage_of_time_value).to be(30)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,7 @@
|
|||
if Gitlab::GitalyClient.enabled?
|
||||
RSpec.configure do |config|
|
||||
config.before(:each) do
|
||||
config.before(:each) do |example|
|
||||
next if example.metadata[:skip_gitaly_mock]
|
||||
allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true)
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue