flipper 0.26.0 → 1.3.6
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 +61 -16
- data/.github/workflows/examples.yml +55 -18
- data/CLAUDE.md +74 -0
- data/Changelog.md +1 -486
- data/Gemfile +23 -11
- data/README.md +31 -27
- data/Rakefile +2 -2
- 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/banner.jpg +0 -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/exe/flipper +5 -0
- data/flipper-cloud.gemspec +19 -0
- data/flipper.gemspec +8 -6
- 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/actor_limit.rb +28 -0
- data/lib/flipper/adapters/cache_base.rb +143 -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 +40 -12
- data/lib/flipper/adapters/http/error.rb +2 -2
- data/lib/flipper/adapters/http.rb +30 -17
- 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 +17 -78
- data/lib/flipper/adapters/poll/poller.rb +2 -125
- data/lib/flipper/adapters/poll.rb +20 -3
- data/lib/flipper/adapters/pstore.rb +17 -11
- data/lib/flipper/adapters/read_only.rb +8 -41
- data/lib/flipper/adapters/strict.rb +45 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
- data/lib/flipper/adapters/sync.rb +0 -4
- data/lib/flipper/adapters/wrapper.rb +54 -0
- data/lib/flipper/cli.rb +263 -0
- data/lib/flipper/cloud/configuration.rb +266 -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 +96 -0
- data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -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 +100 -0
- data/lib/flipper/cloud/telemetry.rb +191 -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 +102 -0
- data/lib/flipper/errors.rb +3 -3
- data/lib/flipper/export.rb +24 -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 +9 -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 +94 -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 +35 -8
- data/lib/flipper/instrumentation/statsd.rb +4 -2
- data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
- data/lib/flipper/instrumentation/subscriber.rb +8 -5
- data/lib/flipper/metadata.rb +8 -1
- data/lib/flipper/middleware/memoizer.rb +30 -14
- data/lib/flipper/model/active_record.rb +23 -0
- data/lib/flipper/poller.rb +118 -0
- data/lib/flipper/serializers/gzip.rb +22 -0
- data/lib/flipper/serializers/json.rb +17 -0
- data/lib/flipper/spec/shared_adapter_specs.rb +105 -63
- data/lib/flipper/test/shared_adapter_test.rb +101 -58
- data/lib/flipper/test_help.rb +43 -0
- data/lib/flipper/typecast.rb +59 -18
- 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 +11 -1
- data/lib/flipper.rb +50 -11
- data/lib/generators/flipper/setup_generator.rb +68 -0
- data/lib/generators/flipper/templates/initializer.rb +45 -0
- data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
- data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
- data/lib/generators/flipper/update_generator.rb +35 -0
- data/package-lock.json +41 -0
- data/package.json +10 -0
- data/spec/fixtures/environment.rb +1 -0
- data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
- data/spec/flipper/adapter_builder_spec.rb +72 -0
- data/spec/flipper/adapter_spec.rb +30 -2
- data/spec/flipper/adapters/actor_limit_spec.rb +20 -0
- data/spec/flipper/adapters/dual_write_spec.rb +2 -2
- data/spec/flipper/adapters/http/client_spec.rb +61 -0
- data/spec/flipper/adapters/http_spec.rb +138 -55
- 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/poll_spec.rb +41 -0
- data/spec/flipper/adapters/read_only_spec.rb +32 -17
- data/spec/flipper/adapters/strict_spec.rb +64 -0
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
- data/spec/flipper/cli_spec.rb +166 -0
- data/spec/flipper/cloud/configuration_spec.rb +251 -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 +107 -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 +208 -0
- data/spec/flipper/cloud_spec.rb +186 -0
- data/spec/flipper/configuration_spec.rb +17 -0
- data/spec/flipper/dsl_spec.rb +54 -76
- data/spec/flipper/engine_spec.rb +374 -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 +453 -39
- 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 +24 -6
- data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +26 -2
- data/spec/flipper/middleware/memoizer_spec.rb +79 -10
- data/spec/flipper/model/active_record_spec.rb +72 -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 +94 -30
- data/spec/spec_helper.rb +18 -18
- data/spec/support/actor_names.yml +1 -0
- data/spec/support/fail_on_output.rb +8 -0
- data/spec/support/fake_backoff_policy.rb +15 -0
- data/spec/support/skippable.rb +18 -0
- data/spec/support/spec_helpers.rb +34 -8
- data/test/adapters/actor_limit_test.rb +20 -0
- data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
- data/test_rails/generators/flipper/update_generator_test.rb +96 -0
- data/test_rails/helper.rb +22 -2
- data/test_rails/system/test_help_test.rb +52 -0
- metadata +203 -20
- 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
data/lib/flipper/cli.rb
ADDED
@@ -0,0 +1,263 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module Flipper
|
4
|
+
class CLI < OptionParser
|
5
|
+
def self.run(argv = ARGV)
|
6
|
+
new.run(argv)
|
7
|
+
end
|
8
|
+
|
9
|
+
# Path to the local Rails application's environment configuration.
|
10
|
+
DEFAULT_REQUIRE = "./config/environment"
|
11
|
+
|
12
|
+
attr_accessor :shell
|
13
|
+
|
14
|
+
def initialize(stdout: $stdout, stderr: $stderr, shell: Bundler::Thor::Base.shell.new)
|
15
|
+
super
|
16
|
+
|
17
|
+
# Program is always flipper, no matter how it's invoked
|
18
|
+
@program_name = 'flipper'
|
19
|
+
@require = ENV.fetch("FLIPPER_REQUIRE", DEFAULT_REQUIRE)
|
20
|
+
@commands = {}
|
21
|
+
|
22
|
+
# Extend whatever shell to support output redirection
|
23
|
+
@shell = shell.extend(ShellOutput)
|
24
|
+
shell.redirect(stdout: stdout, stderr: stderr)
|
25
|
+
|
26
|
+
%w[enable disable].each do |action|
|
27
|
+
command action do |c|
|
28
|
+
c.banner = "Usage: #{c.program_name} [options] <feature>"
|
29
|
+
c.description = "#{action.to_s.capitalize} a feature"
|
30
|
+
|
31
|
+
values = []
|
32
|
+
|
33
|
+
c.on('-a id', '--actor=id', "#{action} for an actor") do |id|
|
34
|
+
values << Actor.new(id)
|
35
|
+
end
|
36
|
+
c.on('-g name', '--group=name', "#{action} for a group") do |name|
|
37
|
+
values << Types::Group.new(name)
|
38
|
+
end
|
39
|
+
c.on('-p NUM', '--percentage-of-actors=NUM', Numeric, "#{action} for a percentage of actors") do |num|
|
40
|
+
values << Types::PercentageOfActors.new(num)
|
41
|
+
end
|
42
|
+
c.on('-t NUM', '--percentage-of-time=NUM', Numeric, "#{action} for a percentage of time") do |num|
|
43
|
+
values << Types::PercentageOfTime.new(num)
|
44
|
+
end
|
45
|
+
c.on('-x expressions', '--expression=NUM', "#{action} for the given expression") do |expression|
|
46
|
+
begin
|
47
|
+
values << Flipper::Expression.build(JSON.parse(expression))
|
48
|
+
rescue JSON::ParserError => e
|
49
|
+
ui.error "JSON parse error #{e.message}"
|
50
|
+
ui.trace(e)
|
51
|
+
exit 1
|
52
|
+
rescue ArgumentError => e
|
53
|
+
ui.error "Invalid expression: #{e.message}"
|
54
|
+
ui.trace(e)
|
55
|
+
exit 1
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
c.action do |feature|
|
60
|
+
f = Flipper.feature(feature)
|
61
|
+
|
62
|
+
if values.empty?
|
63
|
+
f.send(action)
|
64
|
+
else
|
65
|
+
values.each { |value| f.send(action, value) }
|
66
|
+
end
|
67
|
+
|
68
|
+
ui.info feature_details(f)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
command 'list' do |c|
|
74
|
+
c.description = "List defined features"
|
75
|
+
c.action do
|
76
|
+
ui.info feature_summary(Flipper.features)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
command 'show' do |c|
|
81
|
+
c.description = "Show a defined feature"
|
82
|
+
c.action do |feature|
|
83
|
+
ui.info feature_details(Flipper.feature(feature))
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
command 'help' do |c|
|
88
|
+
c.load_environment = false
|
89
|
+
c.action do |command = nil|
|
90
|
+
ui.info command ? @commands[command].help : help
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
on_tail('-r path', "The path to load your application. Default: #{@require}") do |path|
|
95
|
+
@require = path
|
96
|
+
end
|
97
|
+
|
98
|
+
# Options available on all commands
|
99
|
+
on_tail('-h', '--help', 'Print help message') do
|
100
|
+
ui.info help
|
101
|
+
exit
|
102
|
+
end
|
103
|
+
|
104
|
+
# Set help documentation
|
105
|
+
self.banner = "Usage: #{program_name} [options] <command>"
|
106
|
+
separator ""
|
107
|
+
separator "Commands:"
|
108
|
+
|
109
|
+
pad = @commands.keys.map(&:length).max + 2
|
110
|
+
@commands.each do |name, command|
|
111
|
+
separator " #{name.to_s.ljust(pad, " ")} #{command.description}" if command.description
|
112
|
+
end
|
113
|
+
|
114
|
+
separator ""
|
115
|
+
separator "Options:"
|
116
|
+
end
|
117
|
+
|
118
|
+
def run(argv)
|
119
|
+
command, *args = order(argv)
|
120
|
+
|
121
|
+
if @commands[command]
|
122
|
+
load_environment! if @commands[command].load_environment
|
123
|
+
@commands[command].run(args)
|
124
|
+
else
|
125
|
+
ui.info help
|
126
|
+
|
127
|
+
if command
|
128
|
+
ui.error "Unknown command: #{command}"
|
129
|
+
exit 1
|
130
|
+
end
|
131
|
+
end
|
132
|
+
rescue OptionParser::InvalidOption => e
|
133
|
+
ui.error e.message
|
134
|
+
exit 1
|
135
|
+
end
|
136
|
+
|
137
|
+
# Helper method to define a new command
|
138
|
+
def command(name, &block)
|
139
|
+
@commands[name] = Command.new(program_name: "#{program_name} #{name}")
|
140
|
+
block.call(@commands[name])
|
141
|
+
end
|
142
|
+
|
143
|
+
def load_environment!
|
144
|
+
ENV["FLIPPER_CLOUD_LOGGING_ENABLED"] ||= "false"
|
145
|
+
require File.expand_path(@require)
|
146
|
+
# Ensure all of flipper gets loaded if it hasn't already.
|
147
|
+
require 'flipper'
|
148
|
+
rescue LoadError => e
|
149
|
+
ui.error e.message
|
150
|
+
exit 1
|
151
|
+
end
|
152
|
+
|
153
|
+
def feature_summary(features)
|
154
|
+
features = Array(features)
|
155
|
+
padding = features.map { |f| f.key.to_s.length }.max
|
156
|
+
|
157
|
+
features.map do |feature|
|
158
|
+
summary = case feature.state
|
159
|
+
when :on
|
160
|
+
colorize("⏺ enabled", [:GREEN])
|
161
|
+
when :off
|
162
|
+
"⦸ disabled"
|
163
|
+
else
|
164
|
+
"#{colorize("◯ enabled", [:YELLOW])} for " + feature.enabled_gates.map do |gate|
|
165
|
+
case gate.name
|
166
|
+
when :actor
|
167
|
+
pluralize feature.actors_value.size, 'actor', 'actors'
|
168
|
+
when :group
|
169
|
+
pluralize feature.groups_value.size, 'group', 'groups'
|
170
|
+
when :percentage_of_actors
|
171
|
+
"#{feature.percentage_of_actors_value}% of actors"
|
172
|
+
when :percentage_of_time
|
173
|
+
"#{feature.percentage_of_time_value}% of time"
|
174
|
+
when :expression
|
175
|
+
"an expression"
|
176
|
+
end
|
177
|
+
end.join(', ')
|
178
|
+
end
|
179
|
+
|
180
|
+
colorize("%-#{padding}s" % feature.key, [:BOLD, :WHITE]) + " is #{summary}"
|
181
|
+
end.join("\n")
|
182
|
+
end
|
183
|
+
|
184
|
+
def feature_details(feature)
|
185
|
+
summary = case feature.state
|
186
|
+
when :on
|
187
|
+
colorize("⏺ enabled", [:GREEN])
|
188
|
+
when :off
|
189
|
+
"⦸ disabled"
|
190
|
+
else
|
191
|
+
lines = feature.enabled_gates.map do |gate|
|
192
|
+
case gate.name
|
193
|
+
when :actor
|
194
|
+
[ pluralize(feature.actors_value.size, 'actor', 'actors') ] +
|
195
|
+
feature.actors_value.map { |actor| "- #{actor}" }
|
196
|
+
when :group
|
197
|
+
[ pluralize(feature.groups_value.size, 'group', 'groups') ] +
|
198
|
+
feature.groups_value.map { |group| " - #{group}" }
|
199
|
+
when :percentage_of_actors
|
200
|
+
"#{feature.percentage_of_actors_value}% of actors"
|
201
|
+
when :percentage_of_time
|
202
|
+
"#{feature.percentage_of_time_value}% of time"
|
203
|
+
when :expression
|
204
|
+
json = indent(JSON.pretty_generate(feature.expression_value), 2)
|
205
|
+
"the expression: \n#{colorize(json, [:MAGENTA])}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
"#{colorize("◯ conditionally enabled", [:YELLOW])} for:\n" +
|
210
|
+
indent(lines.flatten.join("\n"), 2)
|
211
|
+
end
|
212
|
+
|
213
|
+
"#{colorize(feature.key, [:BOLD, :WHITE])} is #{summary}"
|
214
|
+
end
|
215
|
+
|
216
|
+
def pluralize(count, singular, plural)
|
217
|
+
"#{count} #{count == 1 ? singular : plural}"
|
218
|
+
end
|
219
|
+
|
220
|
+
def colorize(text, colors)
|
221
|
+
ui.add_color(text, *colors)
|
222
|
+
end
|
223
|
+
|
224
|
+
def ui
|
225
|
+
@ui ||= Bundler::UI::Shell.new.tap do |ui|
|
226
|
+
ui.shell = shell
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def indent(text, spaces)
|
231
|
+
text.gsub(/^/, " " * spaces)
|
232
|
+
end
|
233
|
+
|
234
|
+
# Redirect the shell's output to the given stdout and stderr streams
|
235
|
+
module ShellOutput
|
236
|
+
attr_reader :stdout, :stderr
|
237
|
+
|
238
|
+
def redirect(stdout: $stdout, stderr: $stderr)
|
239
|
+
@stdout, @stderr = stdout, stderr
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
class Command < OptionParser
|
244
|
+
attr_accessor :description, :load_environment
|
245
|
+
|
246
|
+
def initialize(program_name: nil)
|
247
|
+
super()
|
248
|
+
@program_name = program_name
|
249
|
+
@load_environment = true
|
250
|
+
@action = lambda { }
|
251
|
+
end
|
252
|
+
|
253
|
+
def run(argv)
|
254
|
+
# Parse argv and call action with arguments
|
255
|
+
@action.call(*permute(argv))
|
256
|
+
end
|
257
|
+
|
258
|
+
def action(&block)
|
259
|
+
@action = block
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
@@ -0,0 +1,266 @@
|
|
1
|
+
require "logger"
|
2
|
+
require "socket"
|
3
|
+
require "flipper/adapters/http"
|
4
|
+
require "flipper/adapters/poll"
|
5
|
+
require "flipper/poller"
|
6
|
+
require "flipper/adapters/dual_write"
|
7
|
+
require "flipper/adapters/sync/synchronizer"
|
8
|
+
require "flipper/cloud/telemetry"
|
9
|
+
require "flipper/cloud/telemetry/instrumenter"
|
10
|
+
require "flipper/cloud/telemetry/submitter"
|
11
|
+
|
12
|
+
module Flipper
|
13
|
+
module Cloud
|
14
|
+
class Configuration
|
15
|
+
# The set of valid ways that syncing can happpen.
|
16
|
+
VALID_SYNC_METHODS = Set[
|
17
|
+
:poll,
|
18
|
+
:webhook,
|
19
|
+
].freeze
|
20
|
+
|
21
|
+
DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
|
22
|
+
|
23
|
+
# Public: The token corresponding to an environment on flippercloud.io.
|
24
|
+
attr_accessor :token
|
25
|
+
|
26
|
+
# Public: The url for http adapter. Really should only be customized for
|
27
|
+
# development work if you are me and you are not me. Feel free to
|
28
|
+
# forget you ever saw this.
|
29
|
+
attr_accessor :url
|
30
|
+
|
31
|
+
# Public: net/http read timeout for all http requests (default: 5).
|
32
|
+
attr_accessor :read_timeout
|
33
|
+
|
34
|
+
# Public: net/http open timeout for all http requests (default: 5).
|
35
|
+
attr_accessor :open_timeout
|
36
|
+
|
37
|
+
# Public: net/http write timeout for all http requests (default: 5).
|
38
|
+
attr_accessor :write_timeout
|
39
|
+
|
40
|
+
# Public: IO stream to send debug output too. Off by default.
|
41
|
+
#
|
42
|
+
# # for example, this would send all http request information to STDOUT
|
43
|
+
# configuration = Flipper::Cloud::Configuration.new
|
44
|
+
# configuration.debug_output = STDOUT
|
45
|
+
attr_accessor :debug_output
|
46
|
+
|
47
|
+
# Public: Instrumenter to use for the Flipper instance returned by
|
48
|
+
# Flipper::Cloud.new (default: Flipper::Instrumenters::Noop).
|
49
|
+
#
|
50
|
+
# # for example, to use active support notifications you could do:
|
51
|
+
# configuration = Flipper::Cloud::Configuration.new
|
52
|
+
# configuration.instrumenter = ActiveSupport::Notifications
|
53
|
+
attr_accessor :instrumenter
|
54
|
+
|
55
|
+
# Public: Local adapter that all reads should go to in order to ensure
|
56
|
+
# latency is low and resiliency is high. This adapter is automatically
|
57
|
+
# kept in sync with cloud.
|
58
|
+
#
|
59
|
+
# # for example, to use active record you could do:
|
60
|
+
# configuration = Flipper::Cloud::Configuration.new
|
61
|
+
# configuration.local_adapter = Flipper::Adapters::ActiveRecord.new
|
62
|
+
attr_accessor :local_adapter
|
63
|
+
|
64
|
+
# Public: The Integer or Float number of seconds between attempts to bring
|
65
|
+
# the local in sync with cloud (default: 10).
|
66
|
+
attr_accessor :sync_interval
|
67
|
+
|
68
|
+
# Public: The secret used to verify if syncs in the middleware should
|
69
|
+
# occur or not.
|
70
|
+
attr_accessor :sync_secret
|
71
|
+
|
72
|
+
# Public: The logger to use for debugging inner workings.
|
73
|
+
attr_accessor :logger
|
74
|
+
|
75
|
+
# Public: Should the logger log or not (default: true).
|
76
|
+
attr_accessor :logging_enabled
|
77
|
+
|
78
|
+
# Public: The telemetry instance to use for tracking feature usage.
|
79
|
+
attr_accessor :telemetry
|
80
|
+
|
81
|
+
# Public: Should telemetry be enabled or not (default: false).
|
82
|
+
attr_accessor :telemetry_enabled
|
83
|
+
|
84
|
+
def initialize(options = {})
|
85
|
+
setup_auth options
|
86
|
+
setup_log options
|
87
|
+
setup_http options
|
88
|
+
setup_sync options
|
89
|
+
setup_adapter options
|
90
|
+
setup_telemetry options
|
91
|
+
end
|
92
|
+
|
93
|
+
# Public: Read or customize the http adapter. Calling without a block will
|
94
|
+
# perform a read. Calling with a block yields the cloud adapter
|
95
|
+
# for customization.
|
96
|
+
#
|
97
|
+
# # for example, to instrument the http calls, you can wrap the http
|
98
|
+
# # adapter with the intsrumented adapter
|
99
|
+
# configuration = Flipper::Cloud::Configuration.new
|
100
|
+
# configuration.adapter do |adapter|
|
101
|
+
# Flipper::Adapters::Instrumented.new(adapter)
|
102
|
+
# end
|
103
|
+
#
|
104
|
+
def adapter(&block)
|
105
|
+
if block_given?
|
106
|
+
@adapter_block = block
|
107
|
+
else
|
108
|
+
@adapter_block.call app_adapter
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Public: Force a sync.
|
113
|
+
def sync
|
114
|
+
Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
|
115
|
+
instrumenter: instrumenter,
|
116
|
+
}).call
|
117
|
+
end
|
118
|
+
|
119
|
+
# Public: The method that will be used to synchronize local adapter with
|
120
|
+
# cloud. (default: :poll, will be :webhook if sync_secret is set).
|
121
|
+
def sync_method
|
122
|
+
sync_secret ? :webhook : :poll
|
123
|
+
end
|
124
|
+
|
125
|
+
# Internal: The http client used by the http adapter. Exposed so we can
|
126
|
+
# use the same client for posting telemetry.
|
127
|
+
def http_client
|
128
|
+
http_adapter.client
|
129
|
+
end
|
130
|
+
|
131
|
+
# Internal: Logs message if logging is enabled.
|
132
|
+
def log(message, level: :debug)
|
133
|
+
return unless logging_enabled
|
134
|
+
logger.send(level, "name=flipper_cloud #{message}")
|
135
|
+
end
|
136
|
+
|
137
|
+
def instrument(name, payload = {}, &block)
|
138
|
+
instrumenter.instrument(name, payload, &block)
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def app_adapter
|
144
|
+
read_adapter = sync_method == :webhook ? local_adapter : poll_adapter
|
145
|
+
Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
|
146
|
+
end
|
147
|
+
|
148
|
+
def poller
|
149
|
+
Flipper::Poller.get(@url + @token, {
|
150
|
+
interval: sync_interval,
|
151
|
+
remote_adapter: http_adapter,
|
152
|
+
instrumenter: instrumenter,
|
153
|
+
}).tap(&:start)
|
154
|
+
end
|
155
|
+
|
156
|
+
def poll_adapter
|
157
|
+
Flipper::Adapters::Poll.new(poller, local_adapter)
|
158
|
+
end
|
159
|
+
|
160
|
+
def http_adapter
|
161
|
+
Flipper::Adapters::Http.new({
|
162
|
+
url: @url,
|
163
|
+
read_timeout: @read_timeout,
|
164
|
+
open_timeout: @open_timeout,
|
165
|
+
write_timeout: @write_timeout,
|
166
|
+
max_retries: 0, # we'll handle retries ourselves
|
167
|
+
debug_output: @debug_output,
|
168
|
+
headers: {
|
169
|
+
"flipper-cloud-token" => @token,
|
170
|
+
"accept-encoding" => "gzip",
|
171
|
+
},
|
172
|
+
})
|
173
|
+
end
|
174
|
+
|
175
|
+
def setup_auth(options)
|
176
|
+
set_option :token, options, required: true
|
177
|
+
end
|
178
|
+
|
179
|
+
def setup_log(options)
|
180
|
+
set_option :logging_enabled, options, default: false, typecast: :boolean
|
181
|
+
set_option :logger, options, from_env: false, default: -> {
|
182
|
+
if logging_enabled
|
183
|
+
Logger.new(STDOUT)
|
184
|
+
else
|
185
|
+
Logger.new("/dev/null")
|
186
|
+
end
|
187
|
+
}
|
188
|
+
end
|
189
|
+
|
190
|
+
def setup_http(options)
|
191
|
+
set_option :url, options, default: DEFAULT_URL
|
192
|
+
set_option :debug_output, options, from_env: false
|
193
|
+
|
194
|
+
if @debug_output.nil? && Flipper::Typecast.to_boolean(ENV["FLIPPER_CLOUD_DEBUG_OUTPUT_STDOUT"])
|
195
|
+
@debug_output = STDOUT
|
196
|
+
end
|
197
|
+
|
198
|
+
set_option :read_timeout, options, default: 5, typecast: :float, minimum: 0.1
|
199
|
+
set_option :open_timeout, options, default: 2, typecast: :float, minimum: 0.1
|
200
|
+
set_option :write_timeout, options, default: 5, typecast: :float, minimum: 0.1
|
201
|
+
end
|
202
|
+
|
203
|
+
def setup_sync(options)
|
204
|
+
set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
|
205
|
+
set_option :sync_secret, options
|
206
|
+
end
|
207
|
+
|
208
|
+
def setup_adapter(options)
|
209
|
+
set_option :local_adapter, options, default: -> { Adapters::Memory.new }, from_env: false
|
210
|
+
@adapter_block = ->(adapter) { adapter }
|
211
|
+
end
|
212
|
+
|
213
|
+
def setup_telemetry(options)
|
214
|
+
# Needs to be after url and token assignments because they are used for
|
215
|
+
# uniqueness in Telemetry.instance_for.
|
216
|
+
set_option :telemetry, options, from_env: false, default: -> {
|
217
|
+
Telemetry.instance_for(self)
|
218
|
+
}
|
219
|
+
|
220
|
+
set_option :telemetry_enabled, options, default: true, typecast: :boolean
|
221
|
+
instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
|
222
|
+
@instrumenter = if telemetry_enabled
|
223
|
+
Telemetry::Instrumenter.new(self, instrumenter)
|
224
|
+
else
|
225
|
+
instrumenter
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
# Internal: Super helper for defining an option that can be set via
|
230
|
+
# options hash or ENV with defaults, typecasting and minimums.
|
231
|
+
def set_option(name, options, default: nil, typecast: nil, minimum: nil, from_env: true, required: false)
|
232
|
+
env_var = "FLIPPER_CLOUD_#{name.to_s.upcase}"
|
233
|
+
value = options.fetch(name) {
|
234
|
+
default_value = default.respond_to?(:call) ? default.call : default
|
235
|
+
if from_env
|
236
|
+
ENV.fetch(env_var, default_value)
|
237
|
+
else
|
238
|
+
default_value
|
239
|
+
end
|
240
|
+
}
|
241
|
+
value = Flipper::Typecast.send("to_#{typecast}", value) if typecast
|
242
|
+
send("#{name}=", value)
|
243
|
+
enforce_minimum(name, minimum) if minimum
|
244
|
+
|
245
|
+
if required
|
246
|
+
option_value = send(name)
|
247
|
+
if option_value.nil? || option_value.empty?
|
248
|
+
message = String.new("Flipper::Cloud #{name} is missing. Please ")
|
249
|
+
message << "set #{env_var} or " if from_env
|
250
|
+
message << "provide #{name} (e.g. Flipper::Cloud.new(#{name}: value))."
|
251
|
+
raise ArgumentError, message
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# Enforce minimum interval for tasks that run on a timer.
|
257
|
+
def enforce_minimum(name, minimum)
|
258
|
+
provided = send(name)
|
259
|
+
if provided && provided < minimum
|
260
|
+
warn "Flipper::Cloud##{name} must be at least #{minimum} seconds but was #{provided}. Using #{minimum} seconds."
|
261
|
+
send(:instance_variable_set, "@#{name}", minimum)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
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
|