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.
Files changed (228) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +61 -16
  4. data/.github/workflows/examples.yml +55 -18
  5. data/CLAUDE.md +74 -0
  6. data/Changelog.md +1 -486
  7. data/Gemfile +23 -11
  8. data/README.md +31 -27
  9. data/Rakefile +2 -2
  10. data/benchmark/enabled_ips.rb +10 -0
  11. data/benchmark/enabled_multiple_actors_ips.rb +20 -0
  12. data/benchmark/enabled_profile.rb +20 -0
  13. data/benchmark/instrumentation_ips.rb +21 -0
  14. data/benchmark/typecast_ips.rb +27 -0
  15. data/docs/images/banner.jpg +0 -0
  16. data/docs/images/flipper_cloud.png +0 -0
  17. data/examples/api/basic.ru +3 -4
  18. data/examples/api/custom_memoized.ru +3 -4
  19. data/examples/api/memoized.ru +3 -4
  20. data/examples/cloud/app.ru +12 -0
  21. data/examples/cloud/backoff_policy.rb +13 -0
  22. data/examples/cloud/basic.rb +22 -0
  23. data/examples/cloud/cloud_setup.rb +20 -0
  24. data/examples/cloud/forked.rb +36 -0
  25. data/examples/cloud/import.rb +17 -0
  26. data/examples/cloud/threaded.rb +33 -0
  27. data/examples/dsl.rb +1 -15
  28. data/examples/enabled_for_actor.rb +4 -2
  29. data/examples/expressions.rb +213 -0
  30. data/examples/mirroring.rb +59 -0
  31. data/examples/strict.rb +18 -0
  32. data/exe/flipper +5 -0
  33. data/flipper-cloud.gemspec +19 -0
  34. data/flipper.gemspec +8 -6
  35. data/lib/flipper/actor.rb +6 -3
  36. data/lib/flipper/adapter.rb +33 -7
  37. data/lib/flipper/adapter_builder.rb +44 -0
  38. data/lib/flipper/adapters/actor_limit.rb +28 -0
  39. data/lib/flipper/adapters/cache_base.rb +143 -0
  40. data/lib/flipper/adapters/dual_write.rb +1 -3
  41. data/lib/flipper/adapters/failover.rb +0 -4
  42. data/lib/flipper/adapters/failsafe.rb +0 -4
  43. data/lib/flipper/adapters/http/client.rb +40 -12
  44. data/lib/flipper/adapters/http/error.rb +2 -2
  45. data/lib/flipper/adapters/http.rb +30 -17
  46. data/lib/flipper/adapters/instrumented.rb +25 -6
  47. data/lib/flipper/adapters/memoizable.rb +33 -21
  48. data/lib/flipper/adapters/memory.rb +81 -46
  49. data/lib/flipper/adapters/operation_logger.rb +17 -78
  50. data/lib/flipper/adapters/poll/poller.rb +2 -125
  51. data/lib/flipper/adapters/poll.rb +20 -3
  52. data/lib/flipper/adapters/pstore.rb +17 -11
  53. data/lib/flipper/adapters/read_only.rb +8 -41
  54. data/lib/flipper/adapters/strict.rb +45 -0
  55. data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
  56. data/lib/flipper/adapters/sync.rb +0 -4
  57. data/lib/flipper/adapters/wrapper.rb +54 -0
  58. data/lib/flipper/cli.rb +263 -0
  59. data/lib/flipper/cloud/configuration.rb +266 -0
  60. data/lib/flipper/cloud/dsl.rb +27 -0
  61. data/lib/flipper/cloud/message_verifier.rb +95 -0
  62. data/lib/flipper/cloud/middleware.rb +63 -0
  63. data/lib/flipper/cloud/routes.rb +14 -0
  64. data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
  65. data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
  66. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  67. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  68. data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
  69. data/lib/flipper/cloud/telemetry.rb +191 -0
  70. data/lib/flipper/cloud.rb +53 -0
  71. data/lib/flipper/configuration.rb +25 -4
  72. data/lib/flipper/dsl.rb +46 -45
  73. data/lib/flipper/engine.rb +102 -0
  74. data/lib/flipper/errors.rb +3 -3
  75. data/lib/flipper/export.rb +24 -0
  76. data/lib/flipper/exporter.rb +17 -0
  77. data/lib/flipper/exporters/json/export.rb +32 -0
  78. data/lib/flipper/exporters/json/v1.rb +33 -0
  79. data/lib/flipper/expression/builder.rb +73 -0
  80. data/lib/flipper/expression/constant.rb +25 -0
  81. data/lib/flipper/expression.rb +71 -0
  82. data/lib/flipper/expressions/all.rb +9 -0
  83. data/lib/flipper/expressions/any.rb +9 -0
  84. data/lib/flipper/expressions/boolean.rb +9 -0
  85. data/lib/flipper/expressions/comparable.rb +13 -0
  86. data/lib/flipper/expressions/duration.rb +28 -0
  87. data/lib/flipper/expressions/equal.rb +9 -0
  88. data/lib/flipper/expressions/greater_than.rb +9 -0
  89. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  90. data/lib/flipper/expressions/less_than.rb +9 -0
  91. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  92. data/lib/flipper/expressions/not_equal.rb +9 -0
  93. data/lib/flipper/expressions/now.rb +9 -0
  94. data/lib/flipper/expressions/number.rb +9 -0
  95. data/lib/flipper/expressions/percentage.rb +9 -0
  96. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  97. data/lib/flipper/expressions/property.rb +9 -0
  98. data/lib/flipper/expressions/random.rb +9 -0
  99. data/lib/flipper/expressions/string.rb +9 -0
  100. data/lib/flipper/expressions/time.rb +9 -0
  101. data/lib/flipper/feature.rb +94 -26
  102. data/lib/flipper/feature_check_context.rb +10 -6
  103. data/lib/flipper/gate.rb +13 -11
  104. data/lib/flipper/gate_values.rb +5 -18
  105. data/lib/flipper/gates/actor.rb +10 -17
  106. data/lib/flipper/gates/boolean.rb +1 -1
  107. data/lib/flipper/gates/expression.rb +75 -0
  108. data/lib/flipper/gates/group.rb +5 -7
  109. data/lib/flipper/gates/percentage_of_actors.rb +10 -13
  110. data/lib/flipper/gates/percentage_of_time.rb +1 -2
  111. data/lib/flipper/identifier.rb +2 -2
  112. data/lib/flipper/instrumentation/log_subscriber.rb +35 -8
  113. data/lib/flipper/instrumentation/statsd.rb +4 -2
  114. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  115. data/lib/flipper/instrumentation/subscriber.rb +8 -5
  116. data/lib/flipper/metadata.rb +8 -1
  117. data/lib/flipper/middleware/memoizer.rb +30 -14
  118. data/lib/flipper/model/active_record.rb +23 -0
  119. data/lib/flipper/poller.rb +118 -0
  120. data/lib/flipper/serializers/gzip.rb +22 -0
  121. data/lib/flipper/serializers/json.rb +17 -0
  122. data/lib/flipper/spec/shared_adapter_specs.rb +105 -63
  123. data/lib/flipper/test/shared_adapter_test.rb +101 -58
  124. data/lib/flipper/test_help.rb +43 -0
  125. data/lib/flipper/typecast.rb +59 -18
  126. data/lib/flipper/types/actor.rb +13 -13
  127. data/lib/flipper/types/group.rb +4 -4
  128. data/lib/flipper/types/percentage.rb +1 -1
  129. data/lib/flipper/version.rb +11 -1
  130. data/lib/flipper.rb +50 -11
  131. data/lib/generators/flipper/setup_generator.rb +68 -0
  132. data/lib/generators/flipper/templates/initializer.rb +45 -0
  133. data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
  134. data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
  135. data/lib/generators/flipper/update_generator.rb +35 -0
  136. data/package-lock.json +41 -0
  137. data/package.json +10 -0
  138. data/spec/fixtures/environment.rb +1 -0
  139. data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
  140. data/spec/flipper/adapter_builder_spec.rb +72 -0
  141. data/spec/flipper/adapter_spec.rb +30 -2
  142. data/spec/flipper/adapters/actor_limit_spec.rb +20 -0
  143. data/spec/flipper/adapters/dual_write_spec.rb +2 -2
  144. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  145. data/spec/flipper/adapters/http_spec.rb +138 -55
  146. data/spec/flipper/adapters/instrumented_spec.rb +29 -11
  147. data/spec/flipper/adapters/memoizable_spec.rb +51 -31
  148. data/spec/flipper/adapters/memory_spec.rb +14 -3
  149. data/spec/flipper/adapters/operation_logger_spec.rb +31 -12
  150. data/spec/flipper/adapters/poll_spec.rb +41 -0
  151. data/spec/flipper/adapters/read_only_spec.rb +32 -17
  152. data/spec/flipper/adapters/strict_spec.rb +64 -0
  153. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  154. data/spec/flipper/cli_spec.rb +166 -0
  155. data/spec/flipper/cloud/configuration_spec.rb +251 -0
  156. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  157. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  158. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  159. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
  160. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  161. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  162. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  163. data/spec/flipper/cloud/telemetry_spec.rb +208 -0
  164. data/spec/flipper/cloud_spec.rb +186 -0
  165. data/spec/flipper/configuration_spec.rb +17 -0
  166. data/spec/flipper/dsl_spec.rb +54 -76
  167. data/spec/flipper/engine_spec.rb +374 -0
  168. data/spec/flipper/export_spec.rb +13 -0
  169. data/spec/flipper/exporter_spec.rb +16 -0
  170. data/spec/flipper/exporters/json/export_spec.rb +60 -0
  171. data/spec/flipper/exporters/json/v1_spec.rb +33 -0
  172. data/spec/flipper/expression/builder_spec.rb +248 -0
  173. data/spec/flipper/expression_spec.rb +188 -0
  174. data/spec/flipper/expressions/all_spec.rb +15 -0
  175. data/spec/flipper/expressions/any_spec.rb +15 -0
  176. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  177. data/spec/flipper/expressions/duration_spec.rb +43 -0
  178. data/spec/flipper/expressions/equal_spec.rb +24 -0
  179. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  180. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  181. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  182. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  183. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  184. data/spec/flipper/expressions/now_spec.rb +11 -0
  185. data/spec/flipper/expressions/number_spec.rb +21 -0
  186. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  187. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  188. data/spec/flipper/expressions/property_spec.rb +13 -0
  189. data/spec/flipper/expressions/random_spec.rb +9 -0
  190. data/spec/flipper/expressions/string_spec.rb +11 -0
  191. data/spec/flipper/expressions/time_spec.rb +13 -0
  192. data/spec/flipper/feature_check_context_spec.rb +17 -17
  193. data/spec/flipper/feature_spec.rb +453 -39
  194. data/spec/flipper/gate_values_spec.rb +2 -33
  195. data/spec/flipper/gates/boolean_spec.rb +1 -1
  196. data/spec/flipper/gates/expression_spec.rb +108 -0
  197. data/spec/flipper/gates/group_spec.rb +2 -3
  198. data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -5
  199. data/spec/flipper/gates/percentage_of_time_spec.rb +2 -2
  200. data/spec/flipper/identifier_spec.rb +4 -5
  201. data/spec/flipper/instrumentation/log_subscriber_spec.rb +24 -6
  202. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +26 -2
  203. data/spec/flipper/middleware/memoizer_spec.rb +79 -10
  204. data/spec/flipper/model/active_record_spec.rb +72 -0
  205. data/spec/flipper/poller_spec.rb +47 -0
  206. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  207. data/spec/flipper/serializers/json_spec.rb +13 -0
  208. data/spec/flipper/typecast_spec.rb +121 -6
  209. data/spec/flipper/types/actor_spec.rb +63 -46
  210. data/spec/flipper/types/group_spec.rb +2 -2
  211. data/spec/flipper_integration_spec.rb +168 -58
  212. data/spec/flipper_spec.rb +94 -30
  213. data/spec/spec_helper.rb +18 -18
  214. data/spec/support/actor_names.yml +1 -0
  215. data/spec/support/fail_on_output.rb +8 -0
  216. data/spec/support/fake_backoff_policy.rb +15 -0
  217. data/spec/support/skippable.rb +18 -0
  218. data/spec/support/spec_helpers.rb +34 -8
  219. data/test/adapters/actor_limit_test.rb +20 -0
  220. data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
  221. data/test_rails/generators/flipper/update_generator_test.rb +96 -0
  222. data/test_rails/helper.rb +22 -2
  223. data/test_rails/system/test_help_test.rb +52 -0
  224. metadata +203 -20
  225. data/.github/workflows/release.yml +0 -44
  226. data/.tool-versions +0 -1
  227. data/lib/flipper/railtie.rb +0 -47
  228. data/spec/flipper/railtie_spec.rb +0 -109
@@ -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