rollout 2.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 37e56080ba30fb08aaa7e718e22fae3cab0745e84a91537e4e5520c2843a63a2
4
+ data.tar.gz: 2b2f8c5a6e7a3ddd8275d818b4a168e7573204c2e259e323ca7a2ea9996062d9
5
+ SHA512:
6
+ metadata.gz: a31905463a16ca7413566460c9a693ab5b9063e74547c05aafe2cf40d5e8f8d83ecb9e85316dd48c3e6b5e5b63311ac222343b485228a695ff5b72f8b0d82a2c
7
+ data.tar.gz: 33fc542bdfe0def2b13a1b3697ba59c1e5f83e813a7c67740d94bf0a9725e17003fdc3fecfa923874883730dde589e0906b5d49ab19ff49c36943e259c28f134
@@ -0,0 +1,95 @@
1
+ version: 2.1
2
+
3
+ workflows:
4
+ main:
5
+ jobs:
6
+ - ruby27
7
+ - ruby26
8
+ - ruby25
9
+ - ruby24
10
+ - ruby23
11
+
12
+ executors:
13
+ ruby27:
14
+ docker:
15
+ - image: circleci/ruby:2.7
16
+ - image: circleci/redis:alpine
17
+ ruby26:
18
+ docker:
19
+ - image: circleci/ruby:2.6
20
+ - image: circleci/redis:alpine
21
+ ruby25:
22
+ docker:
23
+ - image: circleci/ruby:2.5
24
+ - image: circleci/redis:alpine
25
+ ruby24:
26
+ docker:
27
+ - image: circleci/ruby:2.4
28
+ - image: circleci/redis:alpine
29
+ ruby23:
30
+ docker:
31
+ - image: circleci/ruby:2.3
32
+ - image: circleci/redis:alpine
33
+
34
+ commands:
35
+ test:
36
+ steps:
37
+ - restore_cache:
38
+ keys:
39
+ - bundler-{{ checksum "Gemfile.lock" }}
40
+
41
+ - run:
42
+ name: Bundle Install
43
+ command: bundle check --path vendor/bundle || bundle install
44
+
45
+ - save_cache:
46
+ key: bundler-{{ checksum "Gemfile.lock" }}
47
+ paths:
48
+ - vendor/bundle
49
+
50
+ - run:
51
+ name: Run rspec
52
+ command: |
53
+ bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
54
+
55
+ jobs:
56
+ ruby27:
57
+ executor: ruby27
58
+ steps:
59
+ - checkout
60
+ - test
61
+
62
+ - run:
63
+ name: Report Test Coverage
64
+ command: |
65
+ wget https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 -O cc-test-reporter
66
+ chmod +x cc-test-reporter
67
+ ./cc-test-reporter format-coverage -t simplecov -o coverage/codeclimate.json coverage/.resultset.json
68
+ ./cc-test-reporter upload-coverage -i coverage/codeclimate.json
69
+
70
+ - store_test_results:
71
+ path: test_results
72
+
73
+ ruby26:
74
+ executor: ruby26
75
+ steps:
76
+ - checkout
77
+ - test
78
+
79
+ ruby25:
80
+ executor: ruby25
81
+ steps:
82
+ - checkout
83
+ - test
84
+
85
+ ruby24:
86
+ executor: ruby24
87
+ steps:
88
+ - checkout
89
+ - test
90
+
91
+ ruby23:
92
+ executor: ruby23
93
+ steps:
94
+ - checkout
95
+ - test
@@ -0,0 +1,24 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+ *.gem
21
+ gemfiles/*.lock
22
+ .rspec_status
23
+
24
+ ## PROJECT::SPECIFIC
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,11 @@
1
+ AllCops:
2
+ Exclude:
3
+ - 'spec/**/*'
4
+
5
+ Metrics/LineLength:
6
+ Max: 120
7
+ Metrics/MethodLength:
8
+ Max: 20
9
+
10
+ Style/TrailingCommaInArguments:
11
+ EnforcedStyleForMultiline: comma
@@ -0,0 +1,20 @@
1
+ language: ruby
2
+ cache: bundler
3
+ sudo: false
4
+ services:
5
+ - redis-server
6
+ rvm:
7
+ - 2.6
8
+ - 2.5
9
+ - 2.4
10
+ - 2.3
11
+ - 2.2
12
+ - 2.1
13
+ - 2.0
14
+ env:
15
+ - USE_REAL_REDIS=true
16
+ gemfile:
17
+ - gemfiles/redis_3.gemfile
18
+ - gemfiles/redis_4.gemfile
19
+ script:
20
+ - bundle exec rspec
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010-InfinityAndBeyond BitLove, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,201 @@
1
+ # rollout
2
+
3
+ Fast feature flags based on Redis.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout)
6
+ [![CircleCI](https://circleci.com/gh/fetlife/rollout.svg?style=svg)](https://circleci.com/gh/fetlife/rollout)
7
+ [![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/FetLife/rollout)
8
+ [![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](https://codeclimate.com/github/FetLife/rollout/coverage)
9
+
10
+ ## Install it
11
+
12
+ ```bash
13
+ gem install rollout
14
+ ```
15
+
16
+ ## How it works
17
+
18
+ Initialize a rollout object. I assign it to a global var.
19
+
20
+ ```ruby
21
+ require 'redis'
22
+
23
+ $redis = Redis.new
24
+ $rollout = Rollout.new($redis)
25
+ ```
26
+
27
+ or even simpler
28
+
29
+ ```ruby
30
+ require 'redis'
31
+ $rollout = Rollout.new(Redis.current) # Will use REDIS_URL env var or default redis url
32
+ ```
33
+
34
+
35
+ Update data specific to a feature:
36
+
37
+ ```ruby
38
+ $rollout.set_feature_data(:chat, description: 'foo', release_date: 'bar', whatever: 'baz')
39
+ ```
40
+
41
+ Check whether a feature is active for a particular user:
42
+
43
+ ```ruby
44
+ $rollout.active?(:chat, User.first) # => true/false
45
+ ```
46
+
47
+ Check whether a feature is active globally:
48
+
49
+ ```ruby
50
+ $rollout.active?(:chat)
51
+ ```
52
+
53
+ You can activate features using a number of different mechanisms.
54
+
55
+ ## Groups
56
+
57
+ Rollout ships with one group by default: "all", which does exactly what it
58
+ sounds like.
59
+
60
+ You can activate the all group for the chat feature like this:
61
+
62
+ ```ruby
63
+ $rollout.activate_group(:chat, :all)
64
+ ```
65
+
66
+ You might also want to define your own groups. We have one for our caretakers:
67
+
68
+ ```ruby
69
+ $rollout.define_group(:caretakers) do |user|
70
+ user.caretaker?
71
+ end
72
+ ```
73
+
74
+ You can activate multiple groups per feature.
75
+
76
+ Deactivate groups like this:
77
+
78
+ ```ruby
79
+ $rollout.deactivate_group(:chat, :all)
80
+ ```
81
+
82
+ Groups need to be defined every time your app starts. The logic is not persisted
83
+ anywhere.
84
+
85
+ ## Specific Users
86
+
87
+ You might want to let a specific user into a beta test or something. If that
88
+ user isn't part of an existing group, you can let them in specifically:
89
+
90
+ ```ruby
91
+ $rollout.activate_user(:chat, @user)
92
+ ```
93
+
94
+ Deactivate them like this:
95
+
96
+ ```ruby
97
+ $rollout.deactivate_user(:chat, @user)
98
+ ```
99
+
100
+ ## User Percentages
101
+
102
+ If you're rolling out a new feature, you might want to test the waters by
103
+ slowly enabling it for a percentage of your users.
104
+
105
+ ```ruby
106
+ $rollout.activate_percentage(:chat, 20)
107
+ ```
108
+
109
+ The algorithm for determining which users get let in is this:
110
+
111
+ ```ruby
112
+ CRC32(user.id) < (2**32 - 1) / 100.0 * percentage
113
+ ```
114
+
115
+ So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users
116
+ would remain in as the percentage increases.
117
+
118
+ Deactivate all percentages like this:
119
+
120
+ ```ruby
121
+ $rollout.deactivate_percentage(:chat)
122
+ ```
123
+
124
+ _Note that activating a feature for 100% of users will also make it active
125
+ "globally". That is when calling Rollout#active? without a user object._
126
+
127
+ In some cases you might want to have a feature activated for a random set of
128
+ users. It can come specially handy when using Rollout for split tests.
129
+
130
+ ```ruby
131
+ $rollout = Rollout.new($redis, randomize_percentage: true)
132
+ ```
133
+
134
+ When on `randomize_percentage` will make sure that 50% of users for feature A
135
+ are selected independently from users for feature B.
136
+
137
+ ## Global actions
138
+
139
+ While groups can come in handy, the actual global setter for a feature does not require a group to be passed.
140
+
141
+ ```ruby
142
+ $rollout.activate(:chat)
143
+ ```
144
+
145
+ In that case you can check the global availability of a feature using the following
146
+
147
+ ```ruby
148
+ $rollout.active?(:chat)
149
+ ```
150
+
151
+ And if something is wrong you can set a feature off for everybody using
152
+
153
+ Deactivate everybody at once:
154
+
155
+ ```ruby
156
+ $rollout.deactivate(:chat)
157
+ ```
158
+
159
+ For many of our features, we keep track of error rates using redis, and
160
+ deactivate them automatically when a threshold is reached to prevent service
161
+ failures from cascading. See https://github.com/jamesgolick/degrade for the
162
+ failure detection code.
163
+
164
+ ## Namespacing
165
+
166
+ Rollout separates its keys from other keys in the data store using the
167
+ "feature" keyspace.
168
+
169
+ If you're using redis, you can namespace keys further to support multiple
170
+ environments by using the
171
+ [redis-namespace](https://github.com/resque/redis-namespace) gem.
172
+
173
+ ```ruby
174
+ $ns = Redis::Namespace.new(Rails.env, redis: $redis)
175
+ $rollout = Rollout.new($ns)
176
+ $rollout.activate_group(:chat, :all)
177
+ ```
178
+
179
+ This example would use the "development:feature:chat:groups" key.
180
+
181
+ ## Frontend / UI
182
+
183
+ * [Rollout-Dashboard](https://github.com/fiverr/rollout_dashboard/)
184
+
185
+ ## Implementations in other languages
186
+
187
+ * Python: https://github.com/asenchi/proclaim
188
+ * PHP: https://github.com/opensoft/rollout
189
+ * Clojure: https://github.com/yeller/shoutout
190
+ * Perl: https://metacpan.org/pod/Toggle
191
+
192
+
193
+ ## Contributors
194
+
195
+ * James Golick - Creator - https://github.com/jamesgolick
196
+ * Eric Rafaloff - Maintainer - https://github.com/EricR
197
+
198
+
199
+ ## Copyright
200
+
201
+ Copyright (c) 2010-InfinityAndBeyond BitLove, Inc. See LICENSE for details.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rollout/feature'
4
+ require 'rollout/logging'
5
+ require 'rollout/version'
6
+ require 'zlib'
7
+ require 'set'
8
+ require 'json'
9
+ require 'observer'
10
+
11
+ class Rollout
12
+ include Observable
13
+
14
+ RAND_BASE = (2**32 - 1) / 100.0
15
+
16
+ attr_reader :options, :storage
17
+
18
+ def initialize(storage, opts = {})
19
+ @storage = storage
20
+ @options = opts
21
+ @groups = { all: ->(_user) { true } }
22
+
23
+ extend(Logging) if opts[:logging]
24
+ end
25
+
26
+ def groups
27
+ @groups.keys
28
+ end
29
+
30
+ def activate(feature)
31
+ with_feature(feature) do |f|
32
+ f.percentage = 100
33
+ end
34
+ end
35
+
36
+ def deactivate(feature)
37
+ with_feature(feature, &:clear)
38
+ end
39
+
40
+ def delete(feature)
41
+ features = (@storage.get(features_key) || '').split(',')
42
+ features.delete(feature.to_s)
43
+ @storage.set(features_key, features.join(','))
44
+ @storage.del(key(feature))
45
+
46
+ if respond_to?(:logging)
47
+ logging.delete(feature)
48
+ end
49
+ end
50
+
51
+ def set(feature, desired_state)
52
+ with_feature(feature) do |f|
53
+ if desired_state
54
+ f.percentage = 100
55
+ else
56
+ f.clear
57
+ end
58
+ end
59
+ end
60
+
61
+ def activate_group(feature, group)
62
+ with_feature(feature) do |f|
63
+ f.add_group(group)
64
+ end
65
+ end
66
+
67
+ def deactivate_group(feature, group)
68
+ with_feature(feature) do |f|
69
+ f.remove_group(group)
70
+ end
71
+ end
72
+
73
+ def activate_user(feature, user)
74
+ with_feature(feature) do |f|
75
+ f.add_user(user)
76
+ end
77
+ end
78
+
79
+ def deactivate_user(feature, user)
80
+ with_feature(feature) do |f|
81
+ f.remove_user(user)
82
+ end
83
+ end
84
+
85
+ def activate_users(feature, users)
86
+ with_feature(feature) do |f|
87
+ users.each { |user| f.add_user(user) }
88
+ end
89
+ end
90
+
91
+ def deactivate_users(feature, users)
92
+ with_feature(feature) do |f|
93
+ users.each { |user| f.remove_user(user) }
94
+ end
95
+ end
96
+
97
+ def set_users(feature, users)
98
+ with_feature(feature) do |f|
99
+ f.users = []
100
+ users.each { |user| f.add_user(user) }
101
+ end
102
+ end
103
+
104
+ def define_group(group, &block)
105
+ @groups[group.to_sym] = block
106
+ end
107
+
108
+ def active?(feature, user = nil)
109
+ feature = get(feature)
110
+ feature.active?(self, user)
111
+ end
112
+
113
+ def user_in_active_users?(feature, user = nil)
114
+ feature = get(feature)
115
+ feature.user_in_active_users?(user)
116
+ end
117
+
118
+ def inactive?(feature, user = nil)
119
+ !active?(feature, user)
120
+ end
121
+
122
+ def activate_percentage(feature, percentage)
123
+ with_feature(feature) do |f|
124
+ f.percentage = percentage
125
+ end
126
+ end
127
+
128
+ def deactivate_percentage(feature)
129
+ with_feature(feature) do |f|
130
+ f.percentage = 0
131
+ end
132
+ end
133
+
134
+ def active_in_group?(group, user)
135
+ f = @groups[group.to_sym]
136
+ f&.call(user)
137
+ end
138
+
139
+ def get(feature)
140
+ string = @storage.get(key(feature))
141
+ Feature.new(feature, string, @options)
142
+ end
143
+
144
+ def set_feature_data(feature, data)
145
+ with_feature(feature) do |f|
146
+ f.data.merge!(data) if data.is_a? Hash
147
+ end
148
+ end
149
+
150
+ def clear_feature_data(feature)
151
+ with_feature(feature) do |f|
152
+ f.data = {}
153
+ end
154
+ end
155
+
156
+ def multi_get(*features)
157
+ return [] if features.empty?
158
+
159
+ feature_keys = features.map { |feature| key(feature) }
160
+ @storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) }
161
+ end
162
+
163
+ def features
164
+ (@storage.get(features_key) || '').split(',').map(&:to_sym)
165
+ end
166
+
167
+ def feature_states(user = nil)
168
+ multi_get(*features).each_with_object({}) do |f, hash|
169
+ hash[f.name] = f.active?(self, user)
170
+ end
171
+ end
172
+
173
+ def active_features(user = nil)
174
+ multi_get(*features).select do |f|
175
+ f.active?(self, user)
176
+ end.map(&:name)
177
+ end
178
+
179
+ def clear!
180
+ features.each do |feature|
181
+ with_feature(feature, &:clear)
182
+ @storage.del(key(feature))
183
+ end
184
+
185
+ @storage.del(features_key)
186
+ end
187
+
188
+ def exists?(feature)
189
+ # since redis-rb v4.2, `#exists?` replaces `#exists` which now returns integer value instead of boolean
190
+ # https://github.com/redis/redis-rb/pull/918
191
+ if @storage.respond_to?(:exists?)
192
+ @storage.exists?(key(feature))
193
+ else
194
+ @storage.exists(key(feature))
195
+ end
196
+ end
197
+
198
+ def with_feature(feature)
199
+ f = get(feature)
200
+
201
+ if count_observers > 0
202
+ before = Marshal.load(Marshal.dump(f))
203
+ yield(f)
204
+ save(f)
205
+ changed
206
+ notify_observers(:update, before, f)
207
+ else
208
+ yield(f)
209
+ save(f)
210
+ end
211
+ end
212
+
213
+ private
214
+
215
+ def key(name)
216
+ "feature:#{name}"
217
+ end
218
+
219
+ def features_key
220
+ 'feature:__features__'
221
+ end
222
+
223
+ def save(feature)
224
+ @storage.set(key(feature.name), feature.serialize)
225
+ @storage.set(features_key, (features | [feature.name.to_sym]).join(','))
226
+ end
227
+ end