flipper 0.26.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +19 -13
  4. data/.github/workflows/examples.yml +32 -15
  5. data/Changelog.md +294 -154
  6. data/Gemfile +15 -10
  7. data/README.md +13 -11
  8. data/benchmark/enabled_ips.rb +10 -0
  9. data/benchmark/enabled_multiple_actors_ips.rb +20 -0
  10. data/benchmark/enabled_profile.rb +20 -0
  11. data/benchmark/instrumentation_ips.rb +21 -0
  12. data/benchmark/typecast_ips.rb +27 -0
  13. data/docs/images/flipper_cloud.png +0 -0
  14. data/examples/api/basic.ru +3 -4
  15. data/examples/api/custom_memoized.ru +3 -4
  16. data/examples/api/memoized.ru +3 -4
  17. data/examples/cloud/app.ru +12 -0
  18. data/examples/cloud/backoff_policy.rb +13 -0
  19. data/examples/cloud/basic.rb +22 -0
  20. data/examples/cloud/cloud_setup.rb +20 -0
  21. data/examples/cloud/forked.rb +36 -0
  22. data/examples/cloud/import.rb +17 -0
  23. data/examples/cloud/threaded.rb +33 -0
  24. data/examples/dsl.rb +1 -15
  25. data/examples/enabled_for_actor.rb +4 -2
  26. data/examples/expressions.rb +213 -0
  27. data/examples/mirroring.rb +59 -0
  28. data/examples/strict.rb +18 -0
  29. data/flipper-cloud.gemspec +19 -0
  30. data/flipper.gemspec +3 -5
  31. data/lib/flipper/actor.rb +6 -3
  32. data/lib/flipper/adapter.rb +33 -7
  33. data/lib/flipper/adapter_builder.rb +44 -0
  34. data/lib/flipper/adapters/dual_write.rb +1 -3
  35. data/lib/flipper/adapters/failover.rb +0 -4
  36. data/lib/flipper/adapters/failsafe.rb +0 -4
  37. data/lib/flipper/adapters/http/client.rb +26 -7
  38. data/lib/flipper/adapters/http/error.rb +1 -1
  39. data/lib/flipper/adapters/http.rb +29 -16
  40. data/lib/flipper/adapters/instrumented.rb +25 -6
  41. data/lib/flipper/adapters/memoizable.rb +33 -21
  42. data/lib/flipper/adapters/memory.rb +81 -46
  43. data/lib/flipper/adapters/operation_logger.rb +16 -7
  44. data/lib/flipper/adapters/poll/poller.rb +2 -125
  45. data/lib/flipper/adapters/poll.rb +5 -3
  46. data/lib/flipper/adapters/pstore.rb +17 -11
  47. data/lib/flipper/adapters/read_only.rb +4 -4
  48. data/lib/flipper/adapters/strict.rb +47 -0
  49. data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
  50. data/lib/flipper/adapters/sync.rb +0 -4
  51. data/lib/flipper/cloud/configuration.rb +258 -0
  52. data/lib/flipper/cloud/dsl.rb +27 -0
  53. data/lib/flipper/cloud/message_verifier.rb +95 -0
  54. data/lib/flipper/cloud/middleware.rb +63 -0
  55. data/lib/flipper/cloud/routes.rb +14 -0
  56. data/lib/flipper/cloud/telemetry/backoff_policy.rb +93 -0
  57. data/lib/flipper/cloud/telemetry/instrumenter.rb +26 -0
  58. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  59. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  60. data/lib/flipper/cloud/telemetry/submitter.rb +98 -0
  61. data/lib/flipper/cloud/telemetry.rb +183 -0
  62. data/lib/flipper/cloud.rb +53 -0
  63. data/lib/flipper/configuration.rb +25 -4
  64. data/lib/flipper/dsl.rb +46 -45
  65. data/lib/flipper/engine.rb +88 -0
  66. data/lib/flipper/errors.rb +3 -3
  67. data/lib/flipper/export.rb +26 -0
  68. data/lib/flipper/exporter.rb +17 -0
  69. data/lib/flipper/exporters/json/export.rb +32 -0
  70. data/lib/flipper/exporters/json/v1.rb +33 -0
  71. data/lib/flipper/expression/builder.rb +73 -0
  72. data/lib/flipper/expression/constant.rb +25 -0
  73. data/lib/flipper/expression.rb +71 -0
  74. data/lib/flipper/expressions/all.rb +11 -0
  75. data/lib/flipper/expressions/any.rb +9 -0
  76. data/lib/flipper/expressions/boolean.rb +9 -0
  77. data/lib/flipper/expressions/comparable.rb +13 -0
  78. data/lib/flipper/expressions/duration.rb +28 -0
  79. data/lib/flipper/expressions/equal.rb +9 -0
  80. data/lib/flipper/expressions/greater_than.rb +9 -0
  81. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  82. data/lib/flipper/expressions/less_than.rb +9 -0
  83. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  84. data/lib/flipper/expressions/not_equal.rb +9 -0
  85. data/lib/flipper/expressions/now.rb +9 -0
  86. data/lib/flipper/expressions/number.rb +9 -0
  87. data/lib/flipper/expressions/percentage.rb +9 -0
  88. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  89. data/lib/flipper/expressions/property.rb +9 -0
  90. data/lib/flipper/expressions/random.rb +9 -0
  91. data/lib/flipper/expressions/string.rb +9 -0
  92. data/lib/flipper/expressions/time.rb +9 -0
  93. data/lib/flipper/feature.rb +87 -26
  94. data/lib/flipper/feature_check_context.rb +10 -6
  95. data/lib/flipper/gate.rb +13 -11
  96. data/lib/flipper/gate_values.rb +5 -18
  97. data/lib/flipper/gates/actor.rb +10 -17
  98. data/lib/flipper/gates/boolean.rb +1 -1
  99. data/lib/flipper/gates/expression.rb +75 -0
  100. data/lib/flipper/gates/group.rb +5 -7
  101. data/lib/flipper/gates/percentage_of_actors.rb +10 -13
  102. data/lib/flipper/gates/percentage_of_time.rb +1 -2
  103. data/lib/flipper/identifier.rb +2 -2
  104. data/lib/flipper/instrumentation/log_subscriber.rb +24 -5
  105. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  106. data/lib/flipper/instrumentation/subscriber.rb +8 -1
  107. data/lib/flipper/metadata.rb +5 -1
  108. data/lib/flipper/middleware/memoizer.rb +30 -14
  109. data/lib/flipper/poller.rb +117 -0
  110. data/lib/flipper/serializers/gzip.rb +24 -0
  111. data/lib/flipper/serializers/json.rb +19 -0
  112. data/lib/flipper/spec/shared_adapter_specs.rb +95 -54
  113. data/lib/flipper/test/shared_adapter_test.rb +91 -48
  114. data/lib/flipper/typecast.rb +56 -15
  115. data/lib/flipper/types/actor.rb +13 -13
  116. data/lib/flipper/types/group.rb +4 -4
  117. data/lib/flipper/types/percentage.rb +1 -1
  118. data/lib/flipper/version.rb +1 -1
  119. data/lib/flipper.rb +47 -10
  120. data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
  121. data/spec/flipper/adapter_builder_spec.rb +73 -0
  122. data/spec/flipper/adapter_spec.rb +30 -2
  123. data/spec/flipper/adapters/dual_write_spec.rb +2 -2
  124. data/spec/flipper/adapters/http_spec.rb +64 -8
  125. data/spec/flipper/adapters/instrumented_spec.rb +29 -11
  126. data/spec/flipper/adapters/memoizable_spec.rb +51 -31
  127. data/spec/flipper/adapters/memory_spec.rb +14 -3
  128. data/spec/flipper/adapters/operation_logger_spec.rb +31 -12
  129. data/spec/flipper/adapters/read_only_spec.rb +32 -17
  130. data/spec/flipper/adapters/strict_spec.rb +62 -0
  131. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  132. data/spec/flipper/cloud/configuration_spec.rb +252 -0
  133. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  134. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  135. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  136. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +108 -0
  137. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  138. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  139. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  140. data/spec/flipper/cloud/telemetry_spec.rb +156 -0
  141. data/spec/flipper/cloud_spec.rb +180 -0
  142. data/spec/flipper/configuration_spec.rb +17 -0
  143. data/spec/flipper/dsl_spec.rb +54 -73
  144. data/spec/flipper/engine_spec.rb +291 -0
  145. data/spec/flipper/export_spec.rb +13 -0
  146. data/spec/flipper/exporter_spec.rb +16 -0
  147. data/spec/flipper/exporters/json/export_spec.rb +60 -0
  148. data/spec/flipper/exporters/json/v1_spec.rb +33 -0
  149. data/spec/flipper/expression/builder_spec.rb +248 -0
  150. data/spec/flipper/expression_spec.rb +188 -0
  151. data/spec/flipper/expressions/all_spec.rb +15 -0
  152. data/spec/flipper/expressions/any_spec.rb +15 -0
  153. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  154. data/spec/flipper/expressions/duration_spec.rb +43 -0
  155. data/spec/flipper/expressions/equal_spec.rb +24 -0
  156. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  157. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  158. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  159. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  160. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  161. data/spec/flipper/expressions/now_spec.rb +11 -0
  162. data/spec/flipper/expressions/number_spec.rb +21 -0
  163. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  164. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  165. data/spec/flipper/expressions/property_spec.rb +13 -0
  166. data/spec/flipper/expressions/random_spec.rb +9 -0
  167. data/spec/flipper/expressions/string_spec.rb +11 -0
  168. data/spec/flipper/expressions/time_spec.rb +13 -0
  169. data/spec/flipper/feature_check_context_spec.rb +17 -17
  170. data/spec/flipper/feature_spec.rb +436 -33
  171. data/spec/flipper/gate_values_spec.rb +2 -33
  172. data/spec/flipper/gates/boolean_spec.rb +1 -1
  173. data/spec/flipper/gates/expression_spec.rb +108 -0
  174. data/spec/flipper/gates/group_spec.rb +2 -3
  175. data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -5
  176. data/spec/flipper/gates/percentage_of_time_spec.rb +2 -2
  177. data/spec/flipper/identifier_spec.rb +4 -5
  178. data/spec/flipper/instrumentation/log_subscriber_spec.rb +15 -5
  179. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +25 -1
  180. data/spec/flipper/middleware/memoizer_spec.rb +67 -0
  181. data/spec/flipper/poller_spec.rb +47 -0
  182. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  183. data/spec/flipper/serializers/json_spec.rb +13 -0
  184. data/spec/flipper/typecast_spec.rb +121 -6
  185. data/spec/flipper/types/actor_spec.rb +63 -46
  186. data/spec/flipper/types/group_spec.rb +2 -2
  187. data/spec/flipper_integration_spec.rb +168 -58
  188. data/spec/flipper_spec.rb +92 -28
  189. data/spec/spec_helper.rb +6 -13
  190. data/spec/support/actor_names.yml +1 -0
  191. data/spec/support/climate_control.rb +7 -0
  192. data/spec/support/fake_backoff_policy.rb +15 -0
  193. data/spec/support/skippable.rb +18 -0
  194. data/spec/support/spec_helpers.rb +11 -3
  195. metadata +166 -13
  196. data/.github/workflows/release.yml +0 -44
  197. data/.tool-versions +0 -1
  198. data/lib/flipper/railtie.rb +0 -47
  199. data/spec/flipper/railtie_spec.rb +0 -109
@@ -1,4 +1,5 @@
1
1
  require 'flipper/adapters/sync/synchronizer'
2
+ require 'flipper/poller'
2
3
 
3
4
  module Flipper
4
5
  module Adapters
@@ -6,13 +7,14 @@ module Flipper
6
7
  extend Forwardable
7
8
  include ::Flipper::Adapter
8
9
 
9
- # Public: The name of the adapter.
10
- attr_reader :name, :adapter, :poller
10
+ # Deprecated
11
+ Poller = ::Flipper::Poller
12
+
13
+ attr_reader :adapter, :poller
11
14
 
12
15
  def_delegators :synced_adapter, :features, :get, :get_multi, :get_all, :add, :remove, :clear, :enable, :disable
13
16
 
14
17
  def initialize(poller, adapter)
15
- @name = :poll
16
18
  @adapter = adapter
17
19
  @poller = poller
18
20
  @last_synced_at = 0
@@ -1,3 +1,4 @@
1
+ require 'json'
1
2
  require 'pstore'
2
3
  require 'set'
3
4
  require 'flipper'
@@ -9,19 +10,14 @@ module Flipper
9
10
  class PStore
10
11
  include ::Flipper::Adapter
11
12
 
12
- FeaturesKey = :flipper_features
13
-
14
- # Public: The name of the adapter.
15
- attr_reader :name
16
-
17
13
  # Public: The path to where the file is stored.
18
14
  attr_reader :path
19
15
 
20
16
  # Public
21
17
  def initialize(path = 'flipper.pstore', thread_safe = true)
22
- @name = :pstore
23
18
  @path = path
24
19
  @store = ::PStore.new(path, thread_safe)
20
+ @features_key = :flipper_features
25
21
  end
26
22
 
27
23
  # Public: The set of known features.
@@ -34,7 +30,7 @@ module Flipper
34
30
  # Public: Adds a feature to the set of known features.
35
31
  def add(feature)
36
32
  @store.transaction do
37
- set_add FeaturesKey, feature.key
33
+ set_add @features_key, feature.key
38
34
  end
39
35
  true
40
36
  end
@@ -43,7 +39,7 @@ module Flipper
43
39
  # all the values for the feature.
44
40
  def remove(feature)
45
41
  @store.transaction do
46
- set_delete FeaturesKey, feature.key
42
+ set_delete @features_key, feature.key
47
43
  clear_gates(feature)
48
44
  end
49
45
  true
@@ -88,6 +84,8 @@ module Flipper
88
84
  write key(feature, gate), thing.value.to_s
89
85
  when :set
90
86
  set_add key(feature, gate), thing.value.to_s
87
+ when :json
88
+ write key(feature, gate), Typecast.to_json(thing.value)
91
89
  else
92
90
  raise "#{gate} is not supported by this adapter yet"
93
91
  end
@@ -109,6 +107,10 @@ module Flipper
109
107
  @store.transaction do
110
108
  set_delete key(feature, gate), thing.value.to_s
111
109
  end
110
+ when :json
111
+ @store.transaction do
112
+ delete key(feature, gate)
113
+ end
112
114
  else
113
115
  raise "#{gate} is not supported by this adapter yet"
114
116
  end
@@ -135,7 +137,7 @@ module Flipper
135
137
  end
136
138
 
137
139
  def read_feature_keys
138
- set_members FeaturesKey
140
+ set_members @features_key
139
141
  end
140
142
 
141
143
  def read_many_features(features)
@@ -150,12 +152,16 @@ module Flipper
150
152
  result = {}
151
153
 
152
154
  feature.gates.each do |gate|
155
+ key = key(feature, gate)
153
156
  result[gate.key] =
154
157
  case gate.data_type
155
158
  when :boolean, :integer
156
- read key(feature, gate)
159
+ read key
157
160
  when :set
158
- set_members key(feature, gate)
161
+ set_members key
162
+ when :json
163
+ value = read(key)
164
+ Typecast.from_json(value)
159
165
  else
160
166
  raise "#{gate} is not supported by this adapter yet"
161
167
  end
@@ -12,19 +12,19 @@ module Flipper
12
12
  end
13
13
  end
14
14
 
15
- # Internal: The name of the adapter.
16
- attr_reader :name
17
-
18
15
  # Public
19
16
  def initialize(adapter)
20
17
  @adapter = adapter
21
- @name = :read_only
22
18
  end
23
19
 
24
20
  def features
25
21
  @adapter.features
26
22
  end
27
23
 
24
+ def read_only?
25
+ true
26
+ end
27
+
28
28
  def get(feature)
29
29
  @adapter.get(feature)
30
30
  end
@@ -0,0 +1,47 @@
1
+ module Flipper
2
+ module Adapters
3
+ # An adapter that ensures a feature exists before checking it.
4
+ class Strict
5
+ extend Forwardable
6
+ include ::Flipper::Adapter
7
+ attr_reader :name, :adapter, :handler
8
+
9
+ class NotFound < ::Flipper::Error
10
+ def initialize(name)
11
+ super "Could not find feature #{name.inspect}. Call `Flipper.add(#{name.inspect})` to create it."
12
+ end
13
+ end
14
+
15
+ HANDLERS = {
16
+ raise: ->(feature) { raise NotFound.new(feature.key) },
17
+ warn: ->(feature) { warn NotFound.new(feature.key).message },
18
+ noop: ->(_) { },
19
+ }
20
+
21
+ def_delegators :@adapter, :features, :get_all, :add, :remove, :clear, :enable, :disable
22
+
23
+ def initialize(adapter, handler = nil, &block)
24
+ @name = :strict
25
+ @adapter = adapter
26
+ @handler = block || HANDLERS.fetch(handler)
27
+ end
28
+
29
+ def get(feature)
30
+ assert_feature_exists(feature)
31
+ @adapter.get(feature)
32
+ end
33
+
34
+ def get_multi(features)
35
+ features.each { |feature| assert_feature_exists(feature) }
36
+ @adapter.get_multi(features)
37
+ end
38
+
39
+ private
40
+
41
+ def assert_feature_exists(feature)
42
+ @handler.call(feature) unless @adapter.features.include?(feature.key)
43
+ end
44
+
45
+ end
46
+ end
47
+ end
@@ -9,6 +9,7 @@ module Flipper
9
9
  class FeatureSynchronizer
10
10
  extend Forwardable
11
11
 
12
+ def_delegator :@local_gate_values, :expression, :local_expression
12
13
  def_delegator :@local_gate_values, :boolean, :local_boolean
13
14
  def_delegator :@local_gate_values, :actors, :local_actors
14
15
  def_delegator :@local_gate_values, :groups, :local_groups
@@ -17,6 +18,7 @@ module Flipper
17
18
  def_delegator :@local_gate_values, :percentage_of_time,
18
19
  :local_percentage_of_time
19
20
 
21
+ def_delegator :@remote_gate_values, :expression, :remote_expression
20
22
  def_delegator :@remote_gate_values, :boolean, :remote_boolean
21
23
  def_delegator :@remote_gate_values, :actors, :remote_actors
22
24
  def_delegator :@remote_gate_values, :groups, :remote_groups
@@ -40,8 +42,9 @@ module Flipper
40
42
  @feature.enable
41
43
  else
42
44
  @feature.disable if local_boolean_enabled?
43
- sync_actors
44
45
  sync_groups
46
+ sync_actors
47
+ sync_expression
45
48
  sync_percentage_of_actors
46
49
  sync_percentage_of_time
47
50
  end
@@ -49,6 +52,12 @@ module Flipper
49
52
 
50
53
  private
51
54
 
55
+ def sync_expression
56
+ return if local_expression == remote_expression
57
+
58
+ @feature.enable_expression remote_expression
59
+ end
60
+
52
61
  def sync_actors
53
62
  remote_actors_added = remote_actors - local_actors
54
63
  remote_actors_added.each do |flipper_id|
@@ -8,9 +8,6 @@ module Flipper
8
8
  class Sync
9
9
  include ::Flipper::Adapter
10
10
 
11
- # Public: The name of the adapter.
12
- attr_reader :name
13
-
14
11
  # Public: The synchronizer that will keep the local and remote in sync.
15
12
  attr_reader :synchronizer
16
13
 
@@ -22,7 +19,6 @@ module Flipper
22
19
  # interval - The Float or Integer number of seconds between syncs from
23
20
  # remote to local. Default value is set in IntervalSynchronizer.
24
21
  def initialize(local, remote, options = {})
25
- @name = :sync
26
22
  @local = local
27
23
  @remote = remote
28
24
  @synchronizer = options.fetch(:synchronizer) do
@@ -0,0 +1,258 @@
1
+ require "logger"
2
+ require "socket"
3
+ require "flipper/adapters/http"
4
+ require "flipper/adapters/poll"
5
+ require "flipper/poller"
6
+ require "flipper/adapters/memory"
7
+ require "flipper/adapters/dual_write"
8
+ require "flipper/adapters/sync/synchronizer"
9
+ require "flipper/cloud/telemetry"
10
+ require "flipper/cloud/telemetry/instrumenter"
11
+ require "flipper/cloud/telemetry/submitter"
12
+
13
+ module Flipper
14
+ module Cloud
15
+ class Configuration
16
+ # The set of valid ways that syncing can happpen.
17
+ VALID_SYNC_METHODS = Set[
18
+ :poll,
19
+ :webhook,
20
+ ].freeze
21
+
22
+ DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
23
+
24
+ # Public: The token corresponding to an environment on flippercloud.io.
25
+ attr_accessor :token
26
+
27
+ # Public: The url for http adapter. Really should only be customized for
28
+ # development work if you are me and you are not me. Feel free to
29
+ # forget you ever saw this.
30
+ attr_accessor :url
31
+
32
+ # Public: net/http read timeout for all http requests (default: 5).
33
+ attr_accessor :read_timeout
34
+
35
+ # Public: net/http open timeout for all http requests (default: 5).
36
+ attr_accessor :open_timeout
37
+
38
+ # Public: net/http write timeout for all http requests (default: 5).
39
+ attr_accessor :write_timeout
40
+
41
+ # Public: IO stream to send debug output too. Off by default.
42
+ #
43
+ # # for example, this would send all http request information to STDOUT
44
+ # configuration = Flipper::Cloud::Configuration.new
45
+ # configuration.debug_output = STDOUT
46
+ attr_accessor :debug_output
47
+
48
+ # Public: Instrumenter to use for the Flipper instance returned by
49
+ # Flipper::Cloud.new (default: Flipper::Instrumenters::Noop).
50
+ #
51
+ # # for example, to use active support notifications you could do:
52
+ # configuration = Flipper::Cloud::Configuration.new
53
+ # configuration.instrumenter = ActiveSupport::Notifications
54
+ attr_accessor :instrumenter
55
+
56
+ # Public: Local adapter that all reads should go to in order to ensure
57
+ # latency is low and resiliency is high. This adapter is automatically
58
+ # kept in sync with cloud.
59
+ #
60
+ # # for example, to use active record you could do:
61
+ # configuration = Flipper::Cloud::Configuration.new
62
+ # configuration.local_adapter = Flipper::Adapters::ActiveRecord.new
63
+ attr_accessor :local_adapter
64
+
65
+ # Public: The Integer or Float number of seconds between attempts to bring
66
+ # the local in sync with cloud (default: 10).
67
+ attr_accessor :sync_interval
68
+
69
+ # Public: The secret used to verify if syncs in the middleware should
70
+ # occur or not.
71
+ attr_accessor :sync_secret
72
+
73
+ # Public: The logger to use for debugging inner workings.
74
+ attr_accessor :logger
75
+
76
+ # Public: Should the logger log or not (default: true).
77
+ attr_accessor :logging_enabled
78
+
79
+ # Public: The telemetry instance to use for tracking feature usage.
80
+ attr_accessor :telemetry
81
+
82
+ # Public: Should telemetry be enabled or not (default: false).
83
+ attr_accessor :telemetry_enabled
84
+
85
+ def initialize(options = {})
86
+ setup_auth options
87
+ setup_log options
88
+ setup_http options
89
+ setup_sync options
90
+ setup_adapter options
91
+ setup_telemetry options
92
+ end
93
+
94
+ # Public: Read or customize the http adapter. Calling without a block will
95
+ # perform a read. Calling with a block yields the cloud adapter
96
+ # for customization.
97
+ #
98
+ # # for example, to instrument the http calls, you can wrap the http
99
+ # # adapter with the intsrumented adapter
100
+ # configuration = Flipper::Cloud::Configuration.new
101
+ # configuration.adapter do |adapter|
102
+ # Flipper::Adapters::Instrumented.new(adapter)
103
+ # end
104
+ #
105
+ def adapter(&block)
106
+ if block_given?
107
+ @adapter_block = block
108
+ else
109
+ @adapter_block.call app_adapter
110
+ end
111
+ end
112
+
113
+ # Public: Force a sync.
114
+ def sync
115
+ Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
116
+ instrumenter: instrumenter,
117
+ }).call
118
+ end
119
+
120
+ # Public: The method that will be used to synchronize local adapter with
121
+ # cloud. (default: :poll, will be :webhook if sync_secret is set).
122
+ def sync_method
123
+ sync_secret ? :webhook : :poll
124
+ end
125
+
126
+ # Internal: The http client used by the http adapter. Exposed so we can
127
+ # use the same client for posting telemetry.
128
+ def http_client
129
+ http_adapter.client
130
+ end
131
+
132
+ # Internal: Logs message if logging is enabled.
133
+ def log(message, level: :debug)
134
+ return unless logging_enabled
135
+ logger.send(level, "name=flipper_cloud #{message}")
136
+ end
137
+
138
+ private
139
+
140
+ def app_adapter
141
+ read_adapter = sync_method == :webhook ? local_adapter : poll_adapter
142
+ Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
143
+ end
144
+
145
+ def poller
146
+ Flipper::Poller.get(@url + @token, {
147
+ interval: sync_interval,
148
+ remote_adapter: http_adapter,
149
+ instrumenter: instrumenter,
150
+ }).tap(&:start)
151
+ end
152
+
153
+ def poll_adapter
154
+ Flipper::Adapters::Poll.new(poller, local_adapter)
155
+ end
156
+
157
+ def http_adapter
158
+ Flipper::Adapters::Http.new({
159
+ url: @url,
160
+ read_timeout: @read_timeout,
161
+ open_timeout: @open_timeout,
162
+ write_timeout: @write_timeout,
163
+ max_retries: 0, # we'll handle retries ourselves
164
+ debug_output: @debug_output,
165
+ headers: {
166
+ "Flipper-Cloud-Token" => @token,
167
+ },
168
+ })
169
+ end
170
+
171
+ def setup_auth(options)
172
+ set_option :token, options, required: true
173
+ end
174
+
175
+ def setup_log(options)
176
+ set_option :logging_enabled, options, default: true, typecast: :boolean
177
+ set_option :logger, options, from_env: false, default: -> {
178
+ if logging_enabled
179
+ Logger.new(STDOUT)
180
+ else
181
+ Logger.new("/dev/null")
182
+ end
183
+ }
184
+ end
185
+
186
+ def setup_http(options)
187
+ set_option :url, options, default: DEFAULT_URL
188
+ set_option :debug_output, options, from_env: false
189
+ set_option :read_timeout, options, default: 5, typecast: :float, minimum: 0.1
190
+ set_option :open_timeout, options, default: 2, typecast: :float, minimum: 0.1
191
+ set_option :write_timeout, options, default: 5, typecast: :float, minimum: 0.1
192
+ end
193
+
194
+ def setup_sync(options)
195
+ set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
196
+ set_option :sync_secret, options
197
+ end
198
+
199
+ def setup_adapter(options)
200
+ set_option :local_adapter, options, default: -> { Adapters::Memory.new }, from_env: false
201
+ @adapter_block = ->(adapter) { adapter }
202
+ end
203
+
204
+ def setup_telemetry(options)
205
+ # Needs to be after url and token assignments because they are used for
206
+ # uniqueness in Telemetry.instance_for.
207
+ set_option :telemetry, options, from_env: false, default: -> {
208
+ Telemetry.instance_for(self)
209
+ }
210
+
211
+ # This is alpha. Don't use this unless you are me. And you are not me.
212
+ set_option :telemetry_enabled, options, default: false, typecast: :boolean
213
+ instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
214
+ @instrumenter = if telemetry_enabled
215
+ Telemetry::Instrumenter.new(self, instrumenter)
216
+ else
217
+ instrumenter
218
+ end
219
+ end
220
+
221
+ # Internal: Super helper for defining an option that can be set via
222
+ # options hash or ENV with defaults, typecasting and minimums.
223
+ def set_option(name, options, default: nil, typecast: nil, minimum: nil, from_env: true, required: false)
224
+ env_var = "FLIPPER_CLOUD_#{name.to_s.upcase}"
225
+ value = options.fetch(name) {
226
+ default_value = default.respond_to?(:call) ? default.call : default
227
+ if from_env
228
+ ENV.fetch(env_var, default_value)
229
+ else
230
+ default_value
231
+ end
232
+ }
233
+ value = Flipper::Typecast.send("to_#{typecast}", value) if typecast
234
+ send("#{name}=", value)
235
+ enforce_minimum(name, minimum) if minimum
236
+
237
+ if required
238
+ option_value = send(name)
239
+ if option_value.nil? || option_value.empty?
240
+ message = "Flipper::Cloud #{name} is missing. Please "
241
+ message << "set #{env_var} or " if from_env
242
+ message << "provide #{name} (e.g. Flipper::Cloud.new(#{name}: value))."
243
+ raise ArgumentError, message
244
+ end
245
+ end
246
+ end
247
+
248
+ # Enforce minimum interval for tasks that run on a timer.
249
+ def enforce_minimum(name, minimum)
250
+ provided = send(name)
251
+ if provided && provided < minimum
252
+ warn "Flipper::Cloud##{name} must be at least #{minimum} seconds but was #{provided}. Using #{minimum} seconds."
253
+ send(:instance_variable_set, "@#{name}", minimum)
254
+ end
255
+ end
256
+ end
257
+ end
258
+ 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,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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flipper/cloud/message_verifier"
4
+
5
+ module Flipper
6
+ module Cloud
7
+ class Middleware
8
+ # Internal: The path to match for webhook requests.
9
+ WEBHOOK_PATH = %r{\A/webhooks\/?\Z}
10
+ # Internal: The root path to match for requests.
11
+ ROOT_PATH = %r{\A/\Z}
12
+
13
+ def initialize(app, options = {})
14
+ @app = app
15
+ @env_key = options.fetch(:env_key, 'flipper')
16
+ end
17
+
18
+ def call(env)
19
+ dup.call!(env)
20
+ end
21
+
22
+ def call!(env)
23
+ request = Rack::Request.new(env)
24
+ if request.post? && (request.path_info.match(ROOT_PATH) || request.path_info.match(WEBHOOK_PATH))
25
+ status = 200
26
+ headers = {
27
+ "content-type" => "application/json",
28
+ }
29
+ body = "{}"
30
+ payload = request.body.read
31
+ signature = request.env["HTTP_FLIPPER_CLOUD_SIGNATURE"]
32
+ flipper = env.fetch(@env_key)
33
+
34
+ begin
35
+ message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
36
+ if message_verifier.verify(payload, signature)
37
+ begin
38
+ flipper.sync
39
+ body = JSON.generate({
40
+ groups: Flipper.group_names.map { |name| {name: name}}
41
+ })
42
+ rescue Flipper::Adapters::Http::Error => error
43
+ status = error.response.code.to_i == 402 ? 402 : 500
44
+ headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
45
+ headers["Flipper-Cloud-Response-Error-Message"] = error.message
46
+ rescue => error
47
+ status = 500
48
+ headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
49
+ headers["Flipper-Cloud-Response-Error-Message"] = error.message
50
+ end
51
+ end
52
+ rescue MessageVerifier::InvalidSignature
53
+ status = 400
54
+ end
55
+
56
+ [status, headers, [body]]
57
+ else
58
+ @app.call(env)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end