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.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +1 -0
- data/.github/workflows/ci.yml +19 -13
- data/.github/workflows/examples.yml +32 -15
- data/Changelog.md +294 -154
- data/Gemfile +15 -10
- data/README.md +13 -11
- data/benchmark/enabled_ips.rb +10 -0
- data/benchmark/enabled_multiple_actors_ips.rb +20 -0
- data/benchmark/enabled_profile.rb +20 -0
- data/benchmark/instrumentation_ips.rb +21 -0
- data/benchmark/typecast_ips.rb +27 -0
- data/docs/images/flipper_cloud.png +0 -0
- data/examples/api/basic.ru +3 -4
- data/examples/api/custom_memoized.ru +3 -4
- data/examples/api/memoized.ru +3 -4
- data/examples/cloud/app.ru +12 -0
- data/examples/cloud/backoff_policy.rb +13 -0
- data/examples/cloud/basic.rb +22 -0
- data/examples/cloud/cloud_setup.rb +20 -0
- data/examples/cloud/forked.rb +36 -0
- data/examples/cloud/import.rb +17 -0
- data/examples/cloud/threaded.rb +33 -0
- data/examples/dsl.rb +1 -15
- data/examples/enabled_for_actor.rb +4 -2
- data/examples/expressions.rb +213 -0
- data/examples/mirroring.rb +59 -0
- data/examples/strict.rb +18 -0
- data/flipper-cloud.gemspec +19 -0
- data/flipper.gemspec +3 -5
- data/lib/flipper/actor.rb +6 -3
- data/lib/flipper/adapter.rb +33 -7
- data/lib/flipper/adapter_builder.rb +44 -0
- data/lib/flipper/adapters/dual_write.rb +1 -3
- data/lib/flipper/adapters/failover.rb +0 -4
- data/lib/flipper/adapters/failsafe.rb +0 -4
- data/lib/flipper/adapters/http/client.rb +26 -7
- data/lib/flipper/adapters/http/error.rb +1 -1
- data/lib/flipper/adapters/http.rb +29 -16
- data/lib/flipper/adapters/instrumented.rb +25 -6
- data/lib/flipper/adapters/memoizable.rb +33 -21
- data/lib/flipper/adapters/memory.rb +81 -46
- data/lib/flipper/adapters/operation_logger.rb +16 -7
- data/lib/flipper/adapters/poll/poller.rb +2 -125
- data/lib/flipper/adapters/poll.rb +5 -3
- data/lib/flipper/adapters/pstore.rb +17 -11
- data/lib/flipper/adapters/read_only.rb +4 -4
- data/lib/flipper/adapters/strict.rb +47 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
- data/lib/flipper/adapters/sync.rb +0 -4
- data/lib/flipper/cloud/configuration.rb +258 -0
- data/lib/flipper/cloud/dsl.rb +27 -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/telemetry/backoff_policy.rb +93 -0
- data/lib/flipper/cloud/telemetry/instrumenter.rb +26 -0
- data/lib/flipper/cloud/telemetry/metric.rb +39 -0
- data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
- data/lib/flipper/cloud/telemetry/submitter.rb +98 -0
- data/lib/flipper/cloud/telemetry.rb +183 -0
- data/lib/flipper/cloud.rb +53 -0
- data/lib/flipper/configuration.rb +25 -4
- data/lib/flipper/dsl.rb +46 -45
- data/lib/flipper/engine.rb +88 -0
- data/lib/flipper/errors.rb +3 -3
- data/lib/flipper/export.rb +26 -0
- data/lib/flipper/exporter.rb +17 -0
- data/lib/flipper/exporters/json/export.rb +32 -0
- data/lib/flipper/exporters/json/v1.rb +33 -0
- data/lib/flipper/expression/builder.rb +73 -0
- data/lib/flipper/expression/constant.rb +25 -0
- data/lib/flipper/expression.rb +71 -0
- data/lib/flipper/expressions/all.rb +11 -0
- data/lib/flipper/expressions/any.rb +9 -0
- data/lib/flipper/expressions/boolean.rb +9 -0
- data/lib/flipper/expressions/comparable.rb +13 -0
- data/lib/flipper/expressions/duration.rb +28 -0
- data/lib/flipper/expressions/equal.rb +9 -0
- data/lib/flipper/expressions/greater_than.rb +9 -0
- data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
- data/lib/flipper/expressions/less_than.rb +9 -0
- data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
- data/lib/flipper/expressions/not_equal.rb +9 -0
- data/lib/flipper/expressions/now.rb +9 -0
- data/lib/flipper/expressions/number.rb +9 -0
- data/lib/flipper/expressions/percentage.rb +9 -0
- data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
- data/lib/flipper/expressions/property.rb +9 -0
- data/lib/flipper/expressions/random.rb +9 -0
- data/lib/flipper/expressions/string.rb +9 -0
- data/lib/flipper/expressions/time.rb +9 -0
- data/lib/flipper/feature.rb +87 -26
- data/lib/flipper/feature_check_context.rb +10 -6
- data/lib/flipper/gate.rb +13 -11
- data/lib/flipper/gate_values.rb +5 -18
- data/lib/flipper/gates/actor.rb +10 -17
- data/lib/flipper/gates/boolean.rb +1 -1
- data/lib/flipper/gates/expression.rb +75 -0
- data/lib/flipper/gates/group.rb +5 -7
- data/lib/flipper/gates/percentage_of_actors.rb +10 -13
- data/lib/flipper/gates/percentage_of_time.rb +1 -2
- data/lib/flipper/identifier.rb +2 -2
- data/lib/flipper/instrumentation/log_subscriber.rb +24 -5
- data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
- data/lib/flipper/instrumentation/subscriber.rb +8 -1
- data/lib/flipper/metadata.rb +5 -1
- data/lib/flipper/middleware/memoizer.rb +30 -14
- data/lib/flipper/poller.rb +117 -0
- data/lib/flipper/serializers/gzip.rb +24 -0
- data/lib/flipper/serializers/json.rb +19 -0
- data/lib/flipper/spec/shared_adapter_specs.rb +95 -54
- data/lib/flipper/test/shared_adapter_test.rb +91 -48
- data/lib/flipper/typecast.rb +56 -15
- data/lib/flipper/types/actor.rb +13 -13
- data/lib/flipper/types/group.rb +4 -4
- data/lib/flipper/types/percentage.rb +1 -1
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +47 -10
- data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
- data/spec/flipper/adapter_builder_spec.rb +73 -0
- data/spec/flipper/adapter_spec.rb +30 -2
- data/spec/flipper/adapters/dual_write_spec.rb +2 -2
- data/spec/flipper/adapters/http_spec.rb +64 -8
- data/spec/flipper/adapters/instrumented_spec.rb +29 -11
- data/spec/flipper/adapters/memoizable_spec.rb +51 -31
- data/spec/flipper/adapters/memory_spec.rb +14 -3
- data/spec/flipper/adapters/operation_logger_spec.rb +31 -12
- data/spec/flipper/adapters/read_only_spec.rb +32 -17
- data/spec/flipper/adapters/strict_spec.rb +62 -0
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
- data/spec/flipper/cloud/configuration_spec.rb +252 -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/telemetry/backoff_policy_spec.rb +108 -0
- data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
- data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
- data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
- data/spec/flipper/cloud/telemetry_spec.rb +156 -0
- data/spec/flipper/cloud_spec.rb +180 -0
- data/spec/flipper/configuration_spec.rb +17 -0
- data/spec/flipper/dsl_spec.rb +54 -73
- data/spec/flipper/engine_spec.rb +291 -0
- data/spec/flipper/export_spec.rb +13 -0
- data/spec/flipper/exporter_spec.rb +16 -0
- data/spec/flipper/exporters/json/export_spec.rb +60 -0
- data/spec/flipper/exporters/json/v1_spec.rb +33 -0
- data/spec/flipper/expression/builder_spec.rb +248 -0
- data/spec/flipper/expression_spec.rb +188 -0
- data/spec/flipper/expressions/all_spec.rb +15 -0
- data/spec/flipper/expressions/any_spec.rb +15 -0
- data/spec/flipper/expressions/boolean_spec.rb +15 -0
- data/spec/flipper/expressions/duration_spec.rb +43 -0
- data/spec/flipper/expressions/equal_spec.rb +24 -0
- data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
- data/spec/flipper/expressions/greater_than_spec.rb +28 -0
- data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
- data/spec/flipper/expressions/less_than_spec.rb +32 -0
- data/spec/flipper/expressions/not_equal_spec.rb +15 -0
- data/spec/flipper/expressions/now_spec.rb +11 -0
- data/spec/flipper/expressions/number_spec.rb +21 -0
- data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
- data/spec/flipper/expressions/percentage_spec.rb +15 -0
- data/spec/flipper/expressions/property_spec.rb +13 -0
- data/spec/flipper/expressions/random_spec.rb +9 -0
- data/spec/flipper/expressions/string_spec.rb +11 -0
- data/spec/flipper/expressions/time_spec.rb +13 -0
- data/spec/flipper/feature_check_context_spec.rb +17 -17
- data/spec/flipper/feature_spec.rb +436 -33
- data/spec/flipper/gate_values_spec.rb +2 -33
- data/spec/flipper/gates/boolean_spec.rb +1 -1
- data/spec/flipper/gates/expression_spec.rb +108 -0
- data/spec/flipper/gates/group_spec.rb +2 -3
- data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -5
- data/spec/flipper/gates/percentage_of_time_spec.rb +2 -2
- data/spec/flipper/identifier_spec.rb +4 -5
- data/spec/flipper/instrumentation/log_subscriber_spec.rb +15 -5
- data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +25 -1
- data/spec/flipper/middleware/memoizer_spec.rb +67 -0
- data/spec/flipper/poller_spec.rb +47 -0
- data/spec/flipper/serializers/gzip_spec.rb +13 -0
- data/spec/flipper/serializers/json_spec.rb +13 -0
- data/spec/flipper/typecast_spec.rb +121 -6
- data/spec/flipper/types/actor_spec.rb +63 -46
- data/spec/flipper/types/group_spec.rb +2 -2
- data/spec/flipper_integration_spec.rb +168 -58
- data/spec/flipper_spec.rb +92 -28
- data/spec/spec_helper.rb +6 -13
- data/spec/support/actor_names.yml +1 -0
- data/spec/support/climate_control.rb +7 -0
- data/spec/support/fake_backoff_policy.rb +15 -0
- data/spec/support/skippable.rb +18 -0
- data/spec/support/spec_helpers.rb +11 -3
- metadata +166 -13
- data/.github/workflows/release.yml +0 -44
- data/.tool-versions +0 -1
- data/lib/flipper/railtie.rb +0 -47
- 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
|
-
#
|
10
|
-
|
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
|
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
|
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
|
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
|
159
|
+
read key
|
157
160
|
when :set
|
158
|
-
set_members key
|
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
|