flipper 1.3.6 → 1.4.1
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/workflows/ci.yml +9 -6
- data/.github/workflows/examples.yml +5 -4
- data/.github/workflows/release.yml +54 -0
- data/.superset/config.json +4 -0
- data/CLAUDE.md +22 -3
- data/README.md +4 -3
- data/examples/cloud/poll_interval/README.md +111 -0
- data/examples/cloud/poll_interval/client.rb +108 -0
- data/examples/cloud/poll_interval/server.rb +98 -0
- data/examples/expressions.rb +35 -11
- data/lib/flipper/adapter.rb +17 -1
- data/lib/flipper/adapters/actor_limit.rb +27 -1
- data/lib/flipper/adapters/cache_base.rb +21 -3
- data/lib/flipper/adapters/dual_write.rb +6 -2
- data/lib/flipper/adapters/failover.rb +9 -3
- data/lib/flipper/adapters/failsafe.rb +2 -2
- data/lib/flipper/adapters/http/client.rb +15 -4
- data/lib/flipper/adapters/http.rb +37 -2
- data/lib/flipper/adapters/instrumented.rb +2 -2
- data/lib/flipper/adapters/memoizable.rb +3 -3
- data/lib/flipper/adapters/memory.rb +1 -1
- data/lib/flipper/adapters/pstore.rb +1 -1
- data/lib/flipper/adapters/strict.rb +30 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
- data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
- data/lib/flipper/adapters/sync.rb +7 -3
- data/lib/flipper/cli.rb +51 -0
- data/lib/flipper/cloud/configuration.rb +9 -4
- data/lib/flipper/cloud/dsl.rb +2 -2
- data/lib/flipper/cloud/middleware.rb +1 -1
- data/lib/flipper/cloud/migrate.rb +71 -0
- data/lib/flipper/cloud/telemetry.rb +1 -1
- data/lib/flipper/cloud.rb +1 -0
- data/lib/flipper/dsl.rb +1 -1
- data/lib/flipper/expressions/feature_enabled.rb +34 -0
- data/lib/flipper/expressions/time.rb +8 -1
- data/lib/flipper/gates/expression.rb +2 -2
- data/lib/flipper/poller.rb +52 -9
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +17 -1
- data/spec/flipper/adapter_spec.rb +20 -0
- data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
- data/spec/flipper/adapters/dual_write_spec.rb +13 -0
- data/spec/flipper/adapters/failover_spec.rb +12 -0
- data/spec/flipper/adapters/http_spec.rb +240 -0
- data/spec/flipper/adapters/strict_spec.rb +62 -4
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
- data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
- data/spec/flipper/adapters/sync_spec.rb +13 -0
- data/spec/flipper/cli_spec.rb +51 -0
- data/spec/flipper/cloud/configuration_spec.rb +6 -0
- data/spec/flipper/cloud/dsl_spec.rb +10 -2
- data/spec/flipper/cloud/middleware_spec.rb +34 -16
- data/spec/flipper/cloud/migrate_spec.rb +160 -0
- data/spec/flipper/cloud/telemetry_spec.rb +1 -1
- data/spec/flipper/engine_spec.rb +2 -2
- data/spec/flipper/expressions/time_spec.rb +16 -0
- data/spec/flipper/gates/expression_spec.rb +82 -0
- data/spec/flipper/middleware/memoizer_spec.rb +37 -6
- data/spec/flipper/poller_spec.rb +347 -4
- data/spec/flipper_integration_spec.rb +133 -0
- data/spec/flipper_spec.rb +6 -1
- data/spec/spec_helper.rb +7 -0
- metadata +17 -112
- data/lib/flipper/expressions/duration.rb +0 -28
- data/spec/flipper/expressions/duration_spec.rb +0 -43
|
@@ -17,6 +17,9 @@ module Flipper
|
|
|
17
17
|
# Public: The cache key where the set of known features is cached.
|
|
18
18
|
attr_reader :features_cache_key
|
|
19
19
|
|
|
20
|
+
# Public: The cache key where the set of all features with gates is cached.
|
|
21
|
+
attr_reader :get_all_cache_key
|
|
22
|
+
|
|
20
23
|
# Public: Alias expires_in to ttl for compatibility.
|
|
21
24
|
alias_method :expires_in, :ttl
|
|
22
25
|
|
|
@@ -29,16 +32,24 @@ module Flipper
|
|
|
29
32
|
@namespace = "flipper/#{@cache_version}"
|
|
30
33
|
@namespace = @namespace.prepend(prefix) if prefix
|
|
31
34
|
@features_cache_key = "#{@namespace}/features"
|
|
35
|
+
@get_all_cache_key = "#{@namespace}/get_all"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Public: Expire the cache for the set of all features with gates.
|
|
39
|
+
def expire_get_all_cache
|
|
40
|
+
cache_delete @get_all_cache_key
|
|
32
41
|
end
|
|
33
42
|
|
|
34
43
|
# Public: Expire the cache for the set of known feature names.
|
|
35
44
|
def expire_features_cache
|
|
36
45
|
cache_delete @features_cache_key
|
|
46
|
+
expire_get_all_cache
|
|
37
47
|
end
|
|
38
48
|
|
|
39
49
|
# Public: Expire the cache for a given feature.
|
|
40
50
|
def expire_feature_cache(key)
|
|
41
51
|
cache_delete feature_cache_key(key)
|
|
52
|
+
expire_get_all_cache
|
|
42
53
|
end
|
|
43
54
|
|
|
44
55
|
# Public
|
|
@@ -79,9 +90,12 @@ module Flipper
|
|
|
79
90
|
end
|
|
80
91
|
|
|
81
92
|
# Public
|
|
82
|
-
def get_all
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
def get_all(**kwargs)
|
|
94
|
+
cache_fetch(@get_all_cache_key) {
|
|
95
|
+
result = read_all_features(**kwargs)
|
|
96
|
+
cache_write @features_cache_key, result.keys.to_set
|
|
97
|
+
result
|
|
98
|
+
}
|
|
85
99
|
end
|
|
86
100
|
|
|
87
101
|
# Public
|
|
@@ -107,6 +121,10 @@ module Flipper
|
|
|
107
121
|
|
|
108
122
|
private
|
|
109
123
|
|
|
124
|
+
def read_all_features(**kwargs)
|
|
125
|
+
@adapter.get_all(**kwargs)
|
|
126
|
+
end
|
|
127
|
+
|
|
110
128
|
# Private: Returns the Set of known feature keys.
|
|
111
129
|
def read_feature_keys
|
|
112
130
|
cache_fetch(@features_cache_key) { @adapter.features }
|
|
@@ -15,6 +15,10 @@ module Flipper
|
|
|
15
15
|
@remote = remote
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
def adapter_stack
|
|
19
|
+
"#{name}(local: #{@local.adapter_stack}, remote: #{@remote.adapter_stack})"
|
|
20
|
+
end
|
|
21
|
+
|
|
18
22
|
def features
|
|
19
23
|
@local.features
|
|
20
24
|
end
|
|
@@ -27,8 +31,8 @@ module Flipper
|
|
|
27
31
|
@local.get_multi(features)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
def get_all
|
|
31
|
-
@local.get_all
|
|
34
|
+
def get_all(**kwargs)
|
|
35
|
+
@local.get_all(**kwargs)
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def add(feature)
|
|
@@ -13,6 +13,8 @@ module Flipper
|
|
|
13
13
|
# primary is updated
|
|
14
14
|
# :errors - Array of exception types for which to failover
|
|
15
15
|
|
|
16
|
+
attr_reader :primary, :secondary
|
|
17
|
+
|
|
16
18
|
def initialize(primary, secondary, options = {})
|
|
17
19
|
@primary = primary
|
|
18
20
|
@secondary = secondary
|
|
@@ -21,6 +23,10 @@ module Flipper
|
|
|
21
23
|
@errors = options.fetch(:errors, [ StandardError ])
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
def adapter_stack
|
|
27
|
+
"#{name}(primary: #{@primary.adapter_stack}, secondary: #{@secondary.adapter_stack})"
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def features
|
|
25
31
|
@primary.features
|
|
26
32
|
rescue *@errors
|
|
@@ -39,10 +45,10 @@ module Flipper
|
|
|
39
45
|
@secondary.get_multi(features)
|
|
40
46
|
end
|
|
41
47
|
|
|
42
|
-
def get_all
|
|
43
|
-
@primary.get_all
|
|
48
|
+
def get_all(**kwargs)
|
|
49
|
+
@primary.get_all(**kwargs)
|
|
44
50
|
rescue *@errors
|
|
45
|
-
@secondary.get_all
|
|
51
|
+
@secondary.get_all(**kwargs)
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
def add(feature)
|
|
@@ -45,12 +45,19 @@ module Flipper
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def add_header(key, value)
|
|
48
|
-
key =
|
|
49
|
-
@headers[key] = value
|
|
48
|
+
@headers[normalize_header_key(key)] = value
|
|
50
49
|
end
|
|
51
50
|
|
|
52
|
-
def get(path)
|
|
53
|
-
|
|
51
|
+
def get(path, options = {})
|
|
52
|
+
headers = @headers.dup
|
|
53
|
+
|
|
54
|
+
if options[:headers]
|
|
55
|
+
options[:headers].each do |key, value|
|
|
56
|
+
headers[normalize_header_key(key)] = value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
perform Net::HTTP::Get, path, headers
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
def post(path, body = nil)
|
|
@@ -125,6 +132,10 @@ module Flipper
|
|
|
125
132
|
def client_frameworks
|
|
126
133
|
CLIENT_FRAMEWORKS.transform_values { |detect| detect.call rescue nil }.compact
|
|
127
134
|
end
|
|
135
|
+
|
|
136
|
+
def normalize_header_key(key)
|
|
137
|
+
key.to_s.downcase.gsub('_'.freeze, '-'.freeze)
|
|
138
|
+
end
|
|
128
139
|
end
|
|
129
140
|
end
|
|
130
141
|
end
|
|
@@ -22,6 +22,10 @@ module Flipper
|
|
|
22
22
|
write_timeout: options[:write_timeout],
|
|
23
23
|
max_retries: options[:max_retries],
|
|
24
24
|
debug_output: options[:debug_output])
|
|
25
|
+
@last_get_all_etag = nil
|
|
26
|
+
@last_get_all_result = nil
|
|
27
|
+
@last_get_all_response = nil
|
|
28
|
+
@get_all_mutex = Mutex.new
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def get(feature)
|
|
@@ -55,8 +59,29 @@ module Flipper
|
|
|
55
59
|
result
|
|
56
60
|
end
|
|
57
61
|
|
|
58
|
-
def get_all
|
|
59
|
-
|
|
62
|
+
def get_all(cache_bust: false)
|
|
63
|
+
options = {}
|
|
64
|
+
path = "/features?exclude_gate_names=true"
|
|
65
|
+
path += "&_cb=#{Time.now.to_i}" if cache_bust
|
|
66
|
+
etag = @get_all_mutex.synchronize { @last_get_all_etag }
|
|
67
|
+
|
|
68
|
+
if etag && !cache_bust
|
|
69
|
+
options[:headers] = { if_none_match: etag }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
response = @client.get(path, options)
|
|
73
|
+
@get_all_mutex.synchronize { @last_get_all_response = response }
|
|
74
|
+
|
|
75
|
+
if response.is_a?(Net::HTTPNotModified)
|
|
76
|
+
cached_result = @get_all_mutex.synchronize { @last_get_all_result }
|
|
77
|
+
|
|
78
|
+
if cached_result
|
|
79
|
+
return cached_result
|
|
80
|
+
else
|
|
81
|
+
raise Error, response
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
60
85
|
raise Error, response unless response.is_a?(Net::HTTPOK)
|
|
61
86
|
|
|
62
87
|
parsed_response = response.body.empty? ? {} : Typecast.from_json(response.body)
|
|
@@ -71,9 +96,19 @@ module Flipper
|
|
|
71
96
|
feature = Feature.new(key, self)
|
|
72
97
|
result[feature.key] = result_for_feature(feature, gates_by_key[feature.key])
|
|
73
98
|
end
|
|
99
|
+
|
|
100
|
+
@get_all_mutex.synchronize do
|
|
101
|
+
@last_get_all_etag = response['etag'] if response['etag']
|
|
102
|
+
@last_get_all_result = result
|
|
103
|
+
end
|
|
104
|
+
|
|
74
105
|
result
|
|
75
106
|
end
|
|
76
107
|
|
|
108
|
+
def last_get_all_response
|
|
109
|
+
@get_all_mutex.synchronize { @last_get_all_response }
|
|
110
|
+
end
|
|
111
|
+
|
|
77
112
|
def features
|
|
78
113
|
response = @client.get('/features?exclude_gate_names=true')
|
|
79
114
|
raise Error, response unless response.is_a?(Net::HTTPOK)
|
|
@@ -101,14 +101,14 @@ module Flipper
|
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
def get_all
|
|
104
|
+
def get_all(**kwargs)
|
|
105
105
|
default_payload = {
|
|
106
106
|
operation: :get_all,
|
|
107
107
|
adapter_name: @adapter.name,
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
@instrumenter.instrument(InstrumentationName, default_payload) do |payload|
|
|
111
|
-
payload[:result] = @adapter.get_all
|
|
111
|
+
payload[:result] = @adapter.get_all(**kwargs)
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
@@ -81,7 +81,7 @@ module Flipper
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
def get_all
|
|
84
|
+
def get_all(**kwargs)
|
|
85
85
|
if memoizing?
|
|
86
86
|
response = nil
|
|
87
87
|
if cache[@get_all_key]
|
|
@@ -90,7 +90,7 @@ module Flipper
|
|
|
90
90
|
response[key] = cache[key_for(key)]
|
|
91
91
|
end
|
|
92
92
|
else
|
|
93
|
-
response = @adapter.get_all
|
|
93
|
+
response = @adapter.get_all(**kwargs)
|
|
94
94
|
response.each do |key, value|
|
|
95
95
|
cache[key_for(key)] = value
|
|
96
96
|
end
|
|
@@ -103,7 +103,7 @@ module Flipper
|
|
|
103
103
|
response.default_proc = ->(memo, key) { memo[key] = default_config }
|
|
104
104
|
response
|
|
105
105
|
else
|
|
106
|
-
@adapter.get_all
|
|
106
|
+
@adapter.get_all(**kwargs)
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
@@ -10,11 +10,40 @@ module Flipper
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
class << self
|
|
14
|
+
# Returns whether sync mode is enabled for the current thread.
|
|
15
|
+
# When sync mode is enabled, strict checks are not enforced,
|
|
16
|
+
# allowing sync operations to add features and bring local state
|
|
17
|
+
# in line with remote state.
|
|
18
|
+
def sync_mode
|
|
19
|
+
Thread.current[:flipper_strict_sync_mode]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sync_mode=(value)
|
|
23
|
+
Thread.current[:flipper_strict_sync_mode] = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Executes a block with sync mode enabled. Strict checks will
|
|
27
|
+
# not be enforced within the block.
|
|
28
|
+
def with_sync_mode
|
|
29
|
+
old_value = sync_mode
|
|
30
|
+
self.sync_mode = true
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
self.sync_mode = old_value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
13
37
|
def initialize(adapter, handler = nil, &block)
|
|
14
38
|
super(adapter)
|
|
15
39
|
@handler = block || handler
|
|
16
40
|
end
|
|
17
41
|
|
|
42
|
+
def add(feature)
|
|
43
|
+
assert_feature_exists(feature) unless self.class.sync_mode
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
18
47
|
def get(feature)
|
|
19
48
|
assert_feature_exists(feature)
|
|
20
49
|
super
|
|
@@ -28,6 +57,7 @@ module Flipper
|
|
|
28
57
|
private
|
|
29
58
|
|
|
30
59
|
def assert_feature_exists(feature)
|
|
60
|
+
return if self.class.sync_mode
|
|
31
61
|
return if @adapter.features.include?(feature.key)
|
|
32
62
|
|
|
33
63
|
case handler
|
|
@@ -55,7 +55,11 @@ module Flipper
|
|
|
55
55
|
def sync_expression
|
|
56
56
|
return if local_expression == remote_expression
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
if remote_expression.nil?
|
|
59
|
+
@feature.disable_expression
|
|
60
|
+
else
|
|
61
|
+
@feature.enable_expression remote_expression
|
|
62
|
+
end
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
def sync_actors
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "flipper/feature"
|
|
2
2
|
require "flipper/gate_values"
|
|
3
|
+
require "flipper/adapters/actor_limit"
|
|
4
|
+
require "flipper/adapters/strict"
|
|
3
5
|
require "flipper/adapters/sync/feature_synchronizer"
|
|
4
6
|
|
|
5
7
|
module Flipper
|
|
@@ -16,27 +18,33 @@ module Flipper
|
|
|
16
18
|
# options - The Hash of options.
|
|
17
19
|
# :instrumenter - The instrumenter used to instrument.
|
|
18
20
|
# :raise - Should errors be raised (default: true).
|
|
21
|
+
# :cache_bust - Should cache busting be used for remote get_all (default: false).
|
|
19
22
|
def initialize(local, remote, options = {})
|
|
20
23
|
@local = local
|
|
21
24
|
@remote = remote
|
|
22
25
|
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
|
|
23
26
|
@raise = options.fetch(:raise, true)
|
|
27
|
+
@cache_bust = options.fetch(:cache_bust, false)
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
# Public: Forces a sync.
|
|
27
31
|
def call
|
|
28
|
-
@instrumenter.instrument("synchronizer_call.flipper")
|
|
32
|
+
@instrumenter.instrument("synchronizer_call.flipper") do
|
|
33
|
+
Flipper::Adapters::Strict.with_sync_mode do
|
|
34
|
+
Flipper::Adapters::ActorLimit.with_sync_mode { sync }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
private
|
|
32
40
|
|
|
33
41
|
def sync
|
|
34
42
|
local_get_all = @local.get_all
|
|
35
|
-
remote_get_all = @remote.get_all
|
|
43
|
+
remote_get_all = @remote.get_all(cache_bust: @cache_bust)
|
|
36
44
|
|
|
37
45
|
# Sync all the gate values.
|
|
38
46
|
remote_get_all.each do |feature_key, remote_gates_hash|
|
|
39
|
-
feature = Feature.new(feature_key, @local)
|
|
47
|
+
feature = Feature.new(feature_key, @local, instrumenter: @instrumenter)
|
|
40
48
|
# Check if feature_key is in hash before accessing to prevent unintended hash modification
|
|
41
49
|
local_gates_hash = local_get_all.key?(feature_key) ? local_get_all[feature_key] : @local.default_config
|
|
42
50
|
local_gate_values = GateValues.new(local_gates_hash)
|
|
@@ -46,11 +54,11 @@ module Flipper
|
|
|
46
54
|
|
|
47
55
|
# Add features that are missing in local and present in remote.
|
|
48
56
|
features_to_add = remote_get_all.keys - local_get_all.keys
|
|
49
|
-
features_to_add.each { |key| Feature.new(key, @local).add }
|
|
57
|
+
features_to_add.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).add }
|
|
50
58
|
|
|
51
59
|
# Remove features that are present in local and missing in remote.
|
|
52
60
|
features_to_remove = local_get_all.keys - remote_get_all.keys
|
|
53
|
-
features_to_remove.each { |key| Feature.new(key, @local).remove }
|
|
61
|
+
features_to_remove.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).remove }
|
|
54
62
|
|
|
55
63
|
nil
|
|
56
64
|
rescue => exception
|
|
@@ -9,7 +9,7 @@ module Flipper
|
|
|
9
9
|
include ::Flipper::Adapter
|
|
10
10
|
|
|
11
11
|
# Public: The synchronizer that will keep the local and remote in sync.
|
|
12
|
-
attr_reader :synchronizer
|
|
12
|
+
attr_reader :synchronizer, :local, :remote
|
|
13
13
|
|
|
14
14
|
# Public: Build a new sync instance.
|
|
15
15
|
#
|
|
@@ -33,6 +33,10 @@ module Flipper
|
|
|
33
33
|
synchronize
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def adapter_stack
|
|
37
|
+
"#{name}(local: #{@local.adapter_stack}, remote: #{@remote.adapter_stack})"
|
|
38
|
+
end
|
|
39
|
+
|
|
36
40
|
def features
|
|
37
41
|
synchronize
|
|
38
42
|
@local.features
|
|
@@ -48,9 +52,9 @@ module Flipper
|
|
|
48
52
|
@local.get_multi(features)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
def get_all
|
|
55
|
+
def get_all(**kwargs)
|
|
52
56
|
synchronize
|
|
53
|
-
@local.get_all
|
|
57
|
+
@local.get_all(**kwargs)
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def add(feature)
|
data/lib/flipper/cli.rb
CHANGED
|
@@ -84,6 +84,57 @@ module Flipper
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
command 'export' do |c|
|
|
88
|
+
c.description = "Export features as JSON"
|
|
89
|
+
c.action do
|
|
90
|
+
export = Flipper.export(format: :json, version: 1)
|
|
91
|
+
ui.info export.contents
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
command 'cloud' do |c|
|
|
96
|
+
c.description = "Flipper Cloud commands"
|
|
97
|
+
c.action do |subcommand = nil, *args|
|
|
98
|
+
require 'flipper/cloud/migrate'
|
|
99
|
+
|
|
100
|
+
case subcommand
|
|
101
|
+
when 'migrate'
|
|
102
|
+
result = Flipper::Cloud.migrate(Flipper)
|
|
103
|
+
if result.url
|
|
104
|
+
ui.info "Migrating to Flipper Cloud..."
|
|
105
|
+
ui.info result.url
|
|
106
|
+
system("open", result.url)
|
|
107
|
+
else
|
|
108
|
+
message = "Migration failed (HTTP #{result.code})"
|
|
109
|
+
message << ": #{result.message}" if result.message
|
|
110
|
+
ui.error message
|
|
111
|
+
exit 1
|
|
112
|
+
end
|
|
113
|
+
when 'push'
|
|
114
|
+
token = args.first
|
|
115
|
+
unless token
|
|
116
|
+
ui.error "Usage: flipper cloud push <token>"
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
119
|
+
result = Flipper::Cloud.push(token, Flipper)
|
|
120
|
+
if result.code == 204
|
|
121
|
+
ui.info "Successfully pushed features to Flipper Cloud"
|
|
122
|
+
else
|
|
123
|
+
message = "Push failed (HTTP #{result.code})"
|
|
124
|
+
message << ": #{result.message}" if result.message
|
|
125
|
+
ui.error message
|
|
126
|
+
exit 1
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
ui.info "Usage: flipper cloud <command>"
|
|
130
|
+
ui.info ""
|
|
131
|
+
ui.info "Commands:"
|
|
132
|
+
ui.info " migrate Migrate features to a new Flipper Cloud account"
|
|
133
|
+
ui.info " push Push features to an existing Flipper Cloud project"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
87
138
|
command 'help' do |c|
|
|
88
139
|
c.load_environment = false
|
|
89
140
|
c.action do |command = nil|
|
|
@@ -110,9 +110,10 @@ module Flipper
|
|
|
110
110
|
end
|
|
111
111
|
|
|
112
112
|
# Public: Force a sync.
|
|
113
|
-
def sync
|
|
113
|
+
def sync(cache_bust: false)
|
|
114
114
|
Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
|
|
115
115
|
instrumenter: instrumenter,
|
|
116
|
+
cache_bust: cache_bust,
|
|
116
117
|
}).call
|
|
117
118
|
end
|
|
118
119
|
|
|
@@ -141,8 +142,7 @@ module Flipper
|
|
|
141
142
|
private
|
|
142
143
|
|
|
143
144
|
def app_adapter
|
|
144
|
-
|
|
145
|
-
Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
|
|
145
|
+
Flipper::Adapters::DualWrite.new(poll_adapter, http_adapter)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
def poller
|
|
@@ -201,8 +201,13 @@ module Flipper
|
|
|
201
201
|
end
|
|
202
202
|
|
|
203
203
|
def setup_sync(options)
|
|
204
|
-
set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
|
|
205
204
|
set_option :sync_secret, options
|
|
205
|
+
|
|
206
|
+
# 1 hour for webhook, 10 seconds for poll. If using webhooks we don't
|
|
207
|
+
# need to sync as often but we should still sync occasionally to avoid
|
|
208
|
+
# any chance of stale data.
|
|
209
|
+
default_interval = sync_method == :webhook ? 3600 : 10
|
|
210
|
+
set_option :sync_interval, options, default: default_interval, typecast: :float, minimum: 10
|
|
206
211
|
end
|
|
207
212
|
|
|
208
213
|
def setup_adapter(options)
|
data/lib/flipper/cloud/dsl.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Flipper
|
|
|
35
35
|
message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
|
|
36
36
|
if message_verifier.verify(payload, signature)
|
|
37
37
|
begin
|
|
38
|
-
flipper.sync
|
|
38
|
+
flipper.sync(cache_bust: true)
|
|
39
39
|
body = JSON.generate({
|
|
40
40
|
groups: Flipper.group_names.map { |name| {name: name}}
|
|
41
41
|
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "flipper/adapters/http/client"
|
|
2
|
+
require "flipper/typecast"
|
|
3
|
+
|
|
4
|
+
module Flipper
|
|
5
|
+
module Cloud
|
|
6
|
+
MigrateResult = Struct.new(:code, :url, :message, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
DEFAULT_CLOUD_URL = "https://www.flippercloud.io".freeze
|
|
9
|
+
|
|
10
|
+
# Public: Migrate features to Flipper Cloud.
|
|
11
|
+
#
|
|
12
|
+
# flipper - The Flipper instance to export features from (default: Flipper).
|
|
13
|
+
# app_name - Optional String name of the application.
|
|
14
|
+
#
|
|
15
|
+
# Returns a MigrateResult with code, url, and message.
|
|
16
|
+
def self.migrate(flipper = Flipper, app_name: nil)
|
|
17
|
+
export = flipper.export(format: :json, version: 1)
|
|
18
|
+
payload = {
|
|
19
|
+
export: Typecast.from_json(export.contents),
|
|
20
|
+
metadata: {app_name: app_name},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
client = build_client("/api")
|
|
24
|
+
response = client.post("/migrate", Typecast.to_gzip(Typecast.to_json(payload)))
|
|
25
|
+
body = Typecast.from_json(response.body) rescue nil
|
|
26
|
+
|
|
27
|
+
MigrateResult.new(
|
|
28
|
+
code: response.code.to_i,
|
|
29
|
+
url: body&.dig("url"),
|
|
30
|
+
message: body&.dig("error"),
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Public: Push features to an existing Flipper Cloud project.
|
|
35
|
+
#
|
|
36
|
+
# token - The String token for the Cloud environment.
|
|
37
|
+
# flipper - The Flipper instance to export features from (default: Flipper).
|
|
38
|
+
#
|
|
39
|
+
# Returns a MigrateResult with code and message.
|
|
40
|
+
def self.push(token, flipper = Flipper)
|
|
41
|
+
export = flipper.export(format: :json, version: 1)
|
|
42
|
+
|
|
43
|
+
client = build_client("/adapter", headers: {
|
|
44
|
+
"flipper-cloud-token" => token,
|
|
45
|
+
})
|
|
46
|
+
response = client.post("/import", Typecast.to_gzip(export.contents))
|
|
47
|
+
body = Typecast.from_json(response.body) rescue nil
|
|
48
|
+
|
|
49
|
+
MigrateResult.new(
|
|
50
|
+
code: response.code.to_i,
|
|
51
|
+
url: nil,
|
|
52
|
+
message: body&.dig("error"),
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Private: Build an HTTP client for Cloud API requests.
|
|
57
|
+
def self.build_client(path, headers: {})
|
|
58
|
+
base_url = ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_CLOUD_URL)
|
|
59
|
+
|
|
60
|
+
Flipper::Adapters::Http::Client.new(
|
|
61
|
+
url: "#{base_url}#{path}",
|
|
62
|
+
headers: {"content-encoding" => "gzip"}.merge(headers),
|
|
63
|
+
open_timeout: 5,
|
|
64
|
+
read_timeout: 30,
|
|
65
|
+
write_timeout: 30,
|
|
66
|
+
max_retries: 2,
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
private_class_method :build_client
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/flipper/cloud.rb
CHANGED
data/lib/flipper/dsl.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Flipper
|
|
|
10
10
|
# Private: What is being used to instrument all the things.
|
|
11
11
|
attr_reader :instrumenter
|
|
12
12
|
|
|
13
|
-
def_delegators :@adapter, :memoize=, :memoizing?, :import, :export
|
|
13
|
+
def_delegators :@adapter, :memoize=, :memoizing?, :import, :export, :adapter_stack
|
|
14
14
|
|
|
15
15
|
# Public: Returns a new instance of the DSL.
|
|
16
16
|
#
|