Skip to content
Snippets Groups Projects
Commit 671284ba authored by Alejandro Rodríguez's avatar Alejandro Rodríguez Committed by Dmitriy Zaporozhets
Browse files

Add feature toggles through Flipper

parent 322c9be8
No related branches found
No related tags found
No related merge requests found
Showing with 483 additions and 5 deletions
Loading
Loading
@@ -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'
Loading
Loading
@@ -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
Loading
Loading
@@ -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)
Loading
Loading
---
title: Add feature toggles and API endpoints for admins
merge_request: 11747
author:
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
Loading
Loading
@@ -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"
Loading
Loading
@@ -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
Loading
Loading
# 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
}
]
}
```
Loading
Loading
@@ -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
 
Loading
Loading
# 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).
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
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
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
Loading
Loading
@@ -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
Loading
Loading
@@ -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)
Loading
Loading
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
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
Loading
Loading
@@ -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
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
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
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment