flipper 0.28.2 → 1.0.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Changelog.md +27 -0
- data/Gemfile +5 -3
- data/docs/images/flipper_cloud.png +0 -0
- data/examples/cloud/app.ru +12 -0
- data/examples/cloud/basic.rb +22 -0
- data/examples/cloud/cloud_setup.rb +4 -0
- data/examples/cloud/forked.rb +31 -0
- data/examples/cloud/import.rb +17 -0
- data/examples/cloud/threaded.rb +36 -0
- data/examples/dsl.rb +0 -14
- data/flipper-cloud.gemspec +19 -0
- data/flipper.gemspec +3 -2
- data/lib/flipper/cloud/configuration.rb +189 -0
- data/lib/flipper/cloud/dsl.rb +27 -0
- data/lib/flipper/cloud/instrumenter.rb +48 -0
- data/lib/flipper/cloud/message_verifier.rb +95 -0
- data/lib/flipper/cloud/middleware.rb +63 -0
- data/lib/flipper/cloud/routes.rb +14 -0
- data/lib/flipper/cloud.rb +53 -0
- data/lib/flipper/dsl.rb +0 -46
- data/lib/flipper/{railtie.rb → engine.rb} +19 -3
- data/lib/flipper/metadata.rb +5 -1
- data/lib/flipper/spec/shared_adapter_specs.rb +43 -43
- data/lib/flipper/test/shared_adapter_test.rb +43 -43
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +3 -5
- data/spec/flipper/adapters/dual_write_spec.rb +2 -2
- data/spec/flipper/adapters/instrumented_spec.rb +1 -1
- data/spec/flipper/adapters/memoizable_spec.rb +6 -6
- data/spec/flipper/adapters/operation_logger_spec.rb +2 -2
- data/spec/flipper/adapters/read_only_spec.rb +6 -6
- data/spec/flipper/cloud/configuration_spec.rb +269 -0
- data/spec/flipper/cloud/dsl_spec.rb +82 -0
- data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
- data/spec/flipper/cloud/middleware_spec.rb +289 -0
- data/spec/flipper/cloud_spec.rb +180 -0
- data/spec/flipper/dsl_spec.rb +0 -75
- data/spec/flipper/engine_spec.rb +190 -0
- data/spec/flipper_integration_spec.rb +12 -12
- data/spec/flipper_spec.rb +0 -30
- data/spec/spec_helper.rb +0 -12
- data/spec/support/climate_control.rb +7 -0
- metadata +54 -10
- data/spec/flipper/railtie_spec.rb +0 -109
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 157649a8da61471fe5d0a9f88f739f72521a3d78d922650ab40cf36a5c5a6e22
|
4
|
+
data.tar.gz: 5ab348ba8ac11acc776324997f03ee95ee883e1544452ec1199657cac2ecc53c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 36eacd774551b1b91d75c7db4978929820918253c05351068471b5f1c9221f0e0333b511715d4baf11191744476b79497d79ab074c2f9c344eaea2002f07a7fa
|
7
|
+
data.tar.gz: dfa622c2db1a7a9befce00f2e6ce0e12dcff8f32cfb2c8e0d24aa3d9b7daaa83157e2bc2162fe8e444a433dfbec369c6ec66f317f4b3d431a8118be4a60fc3ab
|
data/Changelog.md
CHANGED
@@ -2,6 +2,33 @@
|
|
2
2
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
4
4
|
|
5
|
+
## Unreleased
|
6
|
+
|
7
|
+
### Additions/Changes
|
8
|
+
|
9
|
+
* ui, api: Allow Rack 3 (https://github.com/jnunemaker/flipper/pull/670)
|
10
|
+
* cloud: The `flipper-cloud` gem has been merged into the `flipper` and no longer needs to be added separately. Configure cloud by setting the `FLIPPER_CLOUD_TOKEN` environment variable. (https://github.com/jnunemaker/flipper/pull/743)
|
11
|
+
```diff
|
12
|
+
# Gemfile
|
13
|
+
gem 'flipper'
|
14
|
+
- gem 'flipper-cloud'
|
15
|
+
```
|
16
|
+
|
17
|
+
### Breaking Changes
|
18
|
+
|
19
|
+
* Removed `bool`, `actors`, `time`, `actor`, `percentage_of_actors`, and `percentage_of_time` methods on `Flipper` and `Flipper::DSL`. They are rarely if ever used and conflict with some upcoming features. If you are using them, you can migrate via a search and replace like so:
|
20
|
+
* Change `Flipper.bool` => `Flipper::Types::Boolean.new`
|
21
|
+
* Change `Flipper.boolean` => `Flipper::Types::Boolean.new`
|
22
|
+
* Change `Flipper.actor` => `Flipper::Types::Actor.new`
|
23
|
+
* Change `Flipper.percentage_of_actors` => `Flipper::Types::PercentageOfActors.new`
|
24
|
+
* Change `Flipper.actors` => `Flipper::Types::PercentageOfActors.new`
|
25
|
+
* Change `Flipper.percentage_of_time` => `Flipper::Types::PercentageOfTime.new`
|
26
|
+
* Change `Flipper.time` => `Flipper::Types::PercentageOfTime.new`
|
27
|
+
|
28
|
+
## 0.28.3
|
29
|
+
|
30
|
+
* Updated cloud config to ensure that poll adapter ONLY syncs from cloud to local adapter (and never back to cloud). Shouldn't affect anyone other than making things more safe if an incorrect response is received from the cloud poll endpoint. (https://github.com/jnunemaker/flipper/pull/740)
|
31
|
+
|
5
32
|
## 0.28.2
|
6
33
|
|
7
34
|
* UI: fix path to bundled assets when mounted in another Rack app (https://github.com/jnunemaker/flipper/pull/742)
|
data/Gemfile
CHANGED
@@ -7,15 +7,16 @@ Dir['flipper-*.gemspec'].each do |gemspec|
|
|
7
7
|
end
|
8
8
|
|
9
9
|
gem 'debug'
|
10
|
-
gem 'rake'
|
10
|
+
gem 'rake'
|
11
11
|
gem 'statsd-ruby', '~> 1.2.1'
|
12
12
|
gem 'rspec', '~> 3.0'
|
13
13
|
gem 'rack-test'
|
14
|
+
gem 'rackup'
|
14
15
|
gem 'sqlite3', "~> #{ENV['SQLITE3_VERSION'] || '1.4.1'}"
|
15
|
-
gem 'rails', "~> #{ENV['RAILS_VERSION'] || '7.0.
|
16
|
+
gem 'rails', "~> #{ENV['RAILS_VERSION'] || '7.0.4'}"
|
16
17
|
gem 'minitest', '~> 5.18'
|
17
18
|
gem 'minitest-documentation'
|
18
|
-
gem 'webmock'
|
19
|
+
gem 'webmock'
|
19
20
|
gem 'ice_age'
|
20
21
|
gem 'redis-namespace'
|
21
22
|
gem 'webrick'
|
@@ -23,6 +24,7 @@ gem 'stackprof'
|
|
23
24
|
gem 'benchmark-ips'
|
24
25
|
gem 'stackprof-webnav'
|
25
26
|
gem 'flamegraph'
|
27
|
+
gem 'climate_control'
|
26
28
|
|
27
29
|
group(:guard) do
|
28
30
|
gem 'guard', '~> 2.15'
|
Binary file
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# Usage (from the repo root):
|
2
|
+
# env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> bundle exec rackup examples/cloud/app.ru -p 9999
|
3
|
+
# http://localhost:9999/
|
4
|
+
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'flipper/cloud'
|
7
|
+
|
8
|
+
Flipper.configure do |config|
|
9
|
+
config.default { Flipper::Cloud.new }
|
10
|
+
end
|
11
|
+
|
12
|
+
run Flipper::Cloud.app
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# Usage (from the repo root):
|
2
|
+
# env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/basic.rb
|
3
|
+
|
4
|
+
require_relative "./cloud_setup"
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'flipper/cloud'
|
7
|
+
|
8
|
+
Flipper[:stats].enable
|
9
|
+
|
10
|
+
if Flipper[:stats].enabled?
|
11
|
+
puts 'Enabled!'
|
12
|
+
else
|
13
|
+
puts 'Disabled!'
|
14
|
+
end
|
15
|
+
|
16
|
+
Flipper[:stats].disable
|
17
|
+
|
18
|
+
if Flipper[:stats].enabled?
|
19
|
+
puts 'Enabled!'
|
20
|
+
else
|
21
|
+
puts 'Disabled!'
|
22
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# Usage (from the repo root):
|
2
|
+
# env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/threaded.rb
|
3
|
+
|
4
|
+
require_relative "./cloud_setup"
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'flipper/cloud'
|
7
|
+
|
8
|
+
pids = 5.times.map do |n|
|
9
|
+
fork {
|
10
|
+
# Check every second to see if the feature is enabled
|
11
|
+
threads = []
|
12
|
+
5.times do
|
13
|
+
threads << Thread.new do
|
14
|
+
loop do
|
15
|
+
sleep rand
|
16
|
+
|
17
|
+
if Flipper[:stats].enabled?
|
18
|
+
puts "#{Process.pid} #{Time.now.to_i} Enabled!"
|
19
|
+
else
|
20
|
+
puts "#{Process.pid} #{Time.now.to_i} Disabled!"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
threads.map(&:join)
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
pids.each do |pid|
|
30
|
+
Process.waitpid pid, 0
|
31
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# Usage (from the repo root):
|
2
|
+
# env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/import.rb
|
3
|
+
|
4
|
+
require_relative "./cloud_setup"
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'flipper'
|
7
|
+
require 'flipper/cloud'
|
8
|
+
|
9
|
+
Flipper.enable(:test)
|
10
|
+
Flipper.enable(:search)
|
11
|
+
Flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker"))
|
12
|
+
Flipper.enable_percentage_of_time(:logging, 5)
|
13
|
+
|
14
|
+
cloud = Flipper::Cloud.new
|
15
|
+
|
16
|
+
# makes cloud identical to memory flipper
|
17
|
+
cloud.import(Flipper)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# Usage (from the repo root):
|
2
|
+
# env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/threaded.rb
|
3
|
+
|
4
|
+
require_relative "./cloud_setup"
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'flipper/cloud'
|
7
|
+
require "active_support/notifications"
|
8
|
+
require "active_support/isolated_execution_state"
|
9
|
+
|
10
|
+
ActiveSupport::Notifications.subscribe(/poller\.flipper/) do |*args|
|
11
|
+
p args: args
|
12
|
+
end
|
13
|
+
|
14
|
+
Flipper.configure do |config|
|
15
|
+
config.default {
|
16
|
+
Flipper::Cloud.new(local_adapter: config.adapter, instrumenter: ActiveSupport::Notifications)
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
# Check every second to see if the feature is enabled
|
21
|
+
threads = []
|
22
|
+
10.times do
|
23
|
+
threads << Thread.new do
|
24
|
+
loop do
|
25
|
+
sleep rand
|
26
|
+
|
27
|
+
if Flipper[:stats].enabled?
|
28
|
+
puts "#{Time.now.to_i} Enabled!"
|
29
|
+
else
|
30
|
+
puts "#{Time.now.to_i} Disabled!"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
threads.map(&:join)
|
data/examples/dsl.rb
CHANGED
@@ -47,20 +47,6 @@ puts "stats.enabled?: #{stats.enabled?}"
|
|
47
47
|
puts "stats.enabled? person: #{stats.enabled? person}"
|
48
48
|
puts
|
49
49
|
|
50
|
-
# get an instance of the percentage of time type set to 5
|
51
|
-
puts Flipper.time(5).inspect
|
52
|
-
|
53
|
-
# get an instance of the percentage of actors type set to 15
|
54
|
-
puts Flipper.actors(15).inspect
|
55
|
-
|
56
|
-
# get an instance of an actor using an object that responds to flipper_id
|
57
|
-
responds_to_flipper_id = Struct.new(:flipper_id).new(10)
|
58
|
-
puts Flipper.actor(responds_to_flipper_id).inspect
|
59
|
-
|
60
|
-
# get an instance of an actor using an object
|
61
|
-
actor = Struct.new(:flipper_id).new(22)
|
62
|
-
puts Flipper.actor(actor).inspect
|
63
|
-
|
64
50
|
# register a top level group
|
65
51
|
admins = Flipper.register(:admins) { |actor|
|
66
52
|
actor.respond_to?(:admin?) && actor.admin?
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/flipper/version', __FILE__)
|
3
|
+
require File.expand_path('../lib/flipper/metadata', __FILE__)
|
4
|
+
|
5
|
+
Gem::Specification.new do |gem|
|
6
|
+
gem.authors = ['John Nunemaker']
|
7
|
+
gem.email = 'support@flippercloud.io'
|
8
|
+
gem.summary = '[DEPRECATED] This gem has been merged into the `flipper` gem'
|
9
|
+
gem.license = 'MIT'
|
10
|
+
gem.homepage = 'https://www.flippercloud.io'
|
11
|
+
|
12
|
+
gem.files = [ 'lib/flipper-cloud.rb', 'lib/flipper/version.rb' ]
|
13
|
+
gem.name = 'flipper-cloud'
|
14
|
+
gem.require_paths = ['lib']
|
15
|
+
gem.version = Flipper::VERSION
|
16
|
+
gem.metadata = Flipper::METADATA
|
17
|
+
|
18
|
+
gem.add_dependency 'flipper', "~> #{Flipper::VERSION}"
|
19
|
+
end
|
data/flipper.gemspec
CHANGED
@@ -22,9 +22,9 @@ ignored_test_files.flatten!.uniq!
|
|
22
22
|
|
23
23
|
Gem::Specification.new do |gem|
|
24
24
|
gem.authors = ['John Nunemaker']
|
25
|
-
gem.email =
|
25
|
+
gem.email = 'support@flippercloud.io'
|
26
26
|
gem.summary = 'Feature flipper for ANYTHING'
|
27
|
-
gem.homepage = 'https://
|
27
|
+
gem.homepage = 'https://www.flippercloud.io/docs'
|
28
28
|
gem.license = 'MIT'
|
29
29
|
|
30
30
|
gem.files = `git ls-files`.split("\n") - ignored_files + ['lib/flipper/version.rb']
|
@@ -35,4 +35,5 @@ Gem::Specification.new do |gem|
|
|
35
35
|
gem.metadata = Flipper::METADATA
|
36
36
|
|
37
37
|
gem.add_dependency 'concurrent-ruby', '< 2'
|
38
|
+
gem.add_dependency 'brow', '~> 0.4.1'
|
38
39
|
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
require "socket"
|
2
|
+
require "flipper/adapters/http"
|
3
|
+
require "flipper/adapters/poll"
|
4
|
+
require "flipper/poller"
|
5
|
+
require "flipper/adapters/memory"
|
6
|
+
require "flipper/adapters/dual_write"
|
7
|
+
require "flipper/adapters/sync/synchronizer"
|
8
|
+
require "flipper/cloud/instrumenter"
|
9
|
+
require "brow"
|
10
|
+
|
11
|
+
module Flipper
|
12
|
+
module Cloud
|
13
|
+
class Configuration
|
14
|
+
# The set of valid ways that syncing can happpen.
|
15
|
+
VALID_SYNC_METHODS = Set[
|
16
|
+
:poll,
|
17
|
+
:webhook,
|
18
|
+
].freeze
|
19
|
+
|
20
|
+
DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
|
21
|
+
|
22
|
+
# Private: Keeps track of brow instances so they can be shared across
|
23
|
+
# threads.
|
24
|
+
def self.brow_instances
|
25
|
+
@brow_instances ||= Concurrent::Map.new
|
26
|
+
end
|
27
|
+
|
28
|
+
# Public: The token corresponding to an environment on flippercloud.io.
|
29
|
+
attr_accessor :token
|
30
|
+
|
31
|
+
# Public: The url for http adapter. Really should only be customized for
|
32
|
+
# development work. Feel free to forget you ever saw this.
|
33
|
+
attr_reader :url
|
34
|
+
|
35
|
+
# Public: net/http read timeout for all http requests (default: 5).
|
36
|
+
attr_accessor :read_timeout
|
37
|
+
|
38
|
+
# Public: net/http open timeout for all http requests (default: 5).
|
39
|
+
attr_accessor :open_timeout
|
40
|
+
|
41
|
+
# Public: net/http write timeout for all http requests (default: 5).
|
42
|
+
attr_accessor :write_timeout
|
43
|
+
|
44
|
+
# Public: IO stream to send debug output too. Off by default.
|
45
|
+
#
|
46
|
+
# # for example, this would send all http request information to STDOUT
|
47
|
+
# configuration = Flipper::Cloud::Configuration.new
|
48
|
+
# configuration.debug_output = STDOUT
|
49
|
+
attr_accessor :debug_output
|
50
|
+
|
51
|
+
# Public: Instrumenter to use for the Flipper instance returned by
|
52
|
+
# Flipper::Cloud.new (default: Flipper::Instrumenters::Noop).
|
53
|
+
#
|
54
|
+
# # for example, to use active support notifications you could do:
|
55
|
+
# configuration = Flipper::Cloud::Configuration.new
|
56
|
+
# configuration.instrumenter = ActiveSupport::Notifications
|
57
|
+
attr_accessor :instrumenter
|
58
|
+
|
59
|
+
# Public: Local adapter that all reads should go to in order to ensure
|
60
|
+
# latency is low and resiliency is high. This adapter is automatically
|
61
|
+
# kept in sync with cloud.
|
62
|
+
#
|
63
|
+
# # for example, to use active record you could do:
|
64
|
+
# configuration = Flipper::Cloud::Configuration.new
|
65
|
+
# configuration.local_adapter = Flipper::Adapters::ActiveRecord.new
|
66
|
+
attr_accessor :local_adapter
|
67
|
+
|
68
|
+
# Public: The Integer or Float number of seconds between attempts to bring
|
69
|
+
# the local in sync with cloud (default: 10).
|
70
|
+
attr_accessor :sync_interval
|
71
|
+
|
72
|
+
# Public: The secret used to verify if syncs in the middleware should
|
73
|
+
# occur or not.
|
74
|
+
attr_accessor :sync_secret
|
75
|
+
|
76
|
+
def initialize(options = {})
|
77
|
+
@token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
|
78
|
+
|
79
|
+
if @token.nil?
|
80
|
+
raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token (e.g. Flipper::Cloud.new(token: 'token'))."
|
81
|
+
end
|
82
|
+
|
83
|
+
@read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f }
|
84
|
+
@open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f }
|
85
|
+
@write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f }
|
86
|
+
@sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f }
|
87
|
+
@sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] }
|
88
|
+
@local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new }
|
89
|
+
@debug_output = options[:debug_output]
|
90
|
+
@adapter_block = ->(adapter) { adapter }
|
91
|
+
self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_URL) }
|
92
|
+
|
93
|
+
instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
|
94
|
+
|
95
|
+
# This is alpha. Don't use this unless you are me. And you are not me.
|
96
|
+
cloud_instrument = options.fetch(:cloud_instrument) { ENV["FLIPPER_CLOUD_INSTRUMENT"] == "1" }
|
97
|
+
@instrumenter = if cloud_instrument
|
98
|
+
Instrumenter.new(brow: brow, instrumenter: instrumenter)
|
99
|
+
else
|
100
|
+
instrumenter
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Public: Read or customize the http adapter. Calling without a block will
|
105
|
+
# perform a read. Calling with a block yields the cloud adapter
|
106
|
+
# for customization.
|
107
|
+
#
|
108
|
+
# # for example, to instrument the http calls, you can wrap the http
|
109
|
+
# # adapter with the intsrumented adapter
|
110
|
+
# configuration = Flipper::Cloud::Configuration.new
|
111
|
+
# configuration.adapter do |adapter|
|
112
|
+
# Flipper::Adapters::Instrumented.new(adapter)
|
113
|
+
# end
|
114
|
+
#
|
115
|
+
def adapter(&block)
|
116
|
+
if block_given?
|
117
|
+
@adapter_block = block
|
118
|
+
else
|
119
|
+
@adapter_block.call app_adapter
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Public: Set url for the http adapter.
|
124
|
+
attr_writer :url
|
125
|
+
|
126
|
+
def sync
|
127
|
+
Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
|
128
|
+
instrumenter: instrumenter,
|
129
|
+
}).call
|
130
|
+
end
|
131
|
+
|
132
|
+
def brow
|
133
|
+
self.class.brow_instances.compute_if_absent(url + token) do
|
134
|
+
uri = URI.parse(url)
|
135
|
+
uri.path = "#{uri.path}/events".squeeze("/")
|
136
|
+
|
137
|
+
Brow::Client.new({
|
138
|
+
url: uri.to_s,
|
139
|
+
headers: {
|
140
|
+
"Accept" => "application/json",
|
141
|
+
"Content-Type" => "application/json",
|
142
|
+
"User-Agent" => "Flipper v#{VERSION} via Brow v#{Brow::VERSION}",
|
143
|
+
"Flipper-Cloud-Token" => @token,
|
144
|
+
}
|
145
|
+
})
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Public: The method that will be used to synchronize local adapter with
|
150
|
+
# cloud. (default: :poll, will be :webhook if sync_secret is set).
|
151
|
+
def sync_method
|
152
|
+
sync_secret ? :webhook : :poll
|
153
|
+
end
|
154
|
+
|
155
|
+
private
|
156
|
+
|
157
|
+
def app_adapter
|
158
|
+
read_adapter = sync_method == :webhook ? local_adapter : poll_adapter
|
159
|
+
Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
|
160
|
+
end
|
161
|
+
|
162
|
+
def poller
|
163
|
+
Flipper::Poller.get(@url + @token, {
|
164
|
+
interval: sync_interval,
|
165
|
+
remote_adapter: http_adapter,
|
166
|
+
instrumenter: instrumenter,
|
167
|
+
}).tap(&:start)
|
168
|
+
end
|
169
|
+
|
170
|
+
def poll_adapter
|
171
|
+
Flipper::Adapters::Poll.new(poller, local_adapter)
|
172
|
+
end
|
173
|
+
|
174
|
+
def http_adapter
|
175
|
+
Flipper::Adapters::Http.new({
|
176
|
+
url: @url,
|
177
|
+
read_timeout: @read_timeout,
|
178
|
+
open_timeout: @open_timeout,
|
179
|
+
write_timeout: @write_timeout,
|
180
|
+
max_retries: 0, # we'll handle retries ourselves
|
181
|
+
debug_output: @debug_output,
|
182
|
+
headers: {
|
183
|
+
"Flipper-Cloud-Token" => @token,
|
184
|
+
},
|
185
|
+
})
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Flipper
|
4
|
+
module Cloud
|
5
|
+
class DSL < SimpleDelegator
|
6
|
+
attr_reader :cloud_configuration
|
7
|
+
|
8
|
+
def initialize(cloud_configuration)
|
9
|
+
@cloud_configuration = cloud_configuration
|
10
|
+
super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter)
|
11
|
+
end
|
12
|
+
|
13
|
+
def sync
|
14
|
+
@cloud_configuration.sync
|
15
|
+
end
|
16
|
+
|
17
|
+
def sync_secret
|
18
|
+
@cloud_configuration.sync_secret
|
19
|
+
end
|
20
|
+
|
21
|
+
def inspect
|
22
|
+
inspect_id = ::Kernel::format "%x", (object_id * 2)
|
23
|
+
%(#<#{self.class}:0x#{inspect_id} @cloud_configuration=#{cloud_configuration.inspect}, flipper=#{__getobj__.inspect}>)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require "delegate"
|
2
|
+
require "flipper/instrumenters/noop"
|
3
|
+
|
4
|
+
module Flipper
|
5
|
+
module Cloud
|
6
|
+
class Instrumenter < SimpleDelegator
|
7
|
+
def initialize(options = {})
|
8
|
+
@brow = options.fetch(:brow)
|
9
|
+
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
|
10
|
+
super @instrumenter
|
11
|
+
end
|
12
|
+
|
13
|
+
def instrument(name, payload = {}, &block)
|
14
|
+
result = @instrumenter.instrument(name, payload, &block)
|
15
|
+
push name, payload
|
16
|
+
result
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def push(name, payload)
|
22
|
+
return unless name == Flipper::Feature::InstrumentationName
|
23
|
+
return unless :enabled? == payload[:operation]
|
24
|
+
|
25
|
+
dimensions = {
|
26
|
+
"feature" => payload[:feature_name].to_s,
|
27
|
+
"result" => payload[:result].to_s,
|
28
|
+
}
|
29
|
+
|
30
|
+
if (thing = payload[:thing])
|
31
|
+
dimensions["flipper_id"] = thing.value.to_s
|
32
|
+
end
|
33
|
+
|
34
|
+
if (actors = payload[:actors])
|
35
|
+
dimensions["flipper_ids"] = actors.map { |actor| actor.value.to_s }
|
36
|
+
end
|
37
|
+
|
38
|
+
event = {
|
39
|
+
type: "enabled",
|
40
|
+
dimensions: dimensions,
|
41
|
+
measures: {},
|
42
|
+
ts: Time.now.utc,
|
43
|
+
}
|
44
|
+
@brow.push event
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require "openssl"
|
2
|
+
require "digest/sha2"
|
3
|
+
|
4
|
+
module Flipper
|
5
|
+
module Cloud
|
6
|
+
class MessageVerifier
|
7
|
+
class InvalidSignature < StandardError; end
|
8
|
+
|
9
|
+
DEFAULT_VERSION = "v1"
|
10
|
+
|
11
|
+
def self.header(signature, timestamp, version = DEFAULT_VERSION)
|
12
|
+
raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
|
13
|
+
raise ArgumentError, "signature should be a string" unless signature.is_a?(String)
|
14
|
+
"t=#{timestamp.to_i},#{version}=#{signature}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(secret:, version: DEFAULT_VERSION)
|
18
|
+
@secret = secret
|
19
|
+
@version = version || DEFAULT_VERSION
|
20
|
+
|
21
|
+
raise ArgumentError, "secret should be a string" unless @secret.is_a?(String)
|
22
|
+
raise ArgumentError, "version should be a string" unless @version.is_a?(String)
|
23
|
+
end
|
24
|
+
|
25
|
+
def generate(payload, timestamp)
|
26
|
+
raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
|
27
|
+
raise ArgumentError, "payload should be a string" unless payload.is_a?(String)
|
28
|
+
|
29
|
+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, "#{timestamp.to_i}.#{payload}")
|
30
|
+
end
|
31
|
+
|
32
|
+
def header(signature, timestamp)
|
33
|
+
self.class.header(signature, timestamp, @version)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Public: Verifies the signature header for a given payload.
|
37
|
+
#
|
38
|
+
# Raises a InvalidSignature in the following cases:
|
39
|
+
# - the header does not match the expected format
|
40
|
+
# - no signatures found with the expected scheme
|
41
|
+
# - no signatures matching the expected signature
|
42
|
+
# - a tolerance is provided and the timestamp is not within the
|
43
|
+
# tolerance
|
44
|
+
#
|
45
|
+
# Returns true otherwise.
|
46
|
+
def verify(payload, header, tolerance: nil)
|
47
|
+
begin
|
48
|
+
timestamp, signatures = get_timestamp_and_signatures(header)
|
49
|
+
rescue StandardError
|
50
|
+
raise InvalidSignature, "Unable to extract timestamp and signatures from header"
|
51
|
+
end
|
52
|
+
|
53
|
+
if signatures.empty?
|
54
|
+
raise InvalidSignature, "No signatures found with expected version #{@version}"
|
55
|
+
end
|
56
|
+
|
57
|
+
expected_sig = generate(payload, timestamp)
|
58
|
+
unless signatures.any? { |s| secure_compare(expected_sig, s) }
|
59
|
+
raise InvalidSignature, "No signatures found matching the expected signature for payload"
|
60
|
+
end
|
61
|
+
|
62
|
+
if tolerance && timestamp < Time.now - tolerance
|
63
|
+
raise InvalidSignature, "Timestamp outside the tolerance zone (#{Time.at(timestamp)})"
|
64
|
+
end
|
65
|
+
|
66
|
+
true
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
# Extracts the timestamp and the signature(s) with the desired version
|
72
|
+
# from the header
|
73
|
+
def get_timestamp_and_signatures(header)
|
74
|
+
list_items = header.split(/,\s*/).map { |i| i.split("=", 2) }
|
75
|
+
timestamp = Integer(list_items.select { |i| i[0] == "t" }[0][1])
|
76
|
+
signatures = list_items.select { |i| i[0] == @version }.map { |i| i[1] }
|
77
|
+
[Time.at(timestamp), signatures]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Private
|
81
|
+
def fixed_length_secure_compare(a, b)
|
82
|
+
raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize
|
83
|
+
l = a.unpack "C#{a.bytesize}"
|
84
|
+
res = 0
|
85
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
86
|
+
res == 0
|
87
|
+
end
|
88
|
+
|
89
|
+
# Private
|
90
|
+
def secure_compare(a, b)
|
91
|
+
fixed_length_secure_compare(::Digest::SHA256.digest(a), ::Digest::SHA256.digest(b)) && a == b
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|