flipper 0.16.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. checksums.yaml +5 -5
  2. data/.codeclimate.yml +1 -0
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/dependabot.yml +6 -0
  5. data/.github/workflows/ci.yml +110 -0
  6. data/.github/workflows/examples.yml +105 -0
  7. data/.github/workflows/release.yml +54 -0
  8. data/.rspec +1 -0
  9. data/CLAUDE.md +87 -0
  10. data/Changelog.md +2 -215
  11. data/Dockerfile +1 -1
  12. data/Gemfile +28 -20
  13. data/README.md +72 -62
  14. data/Rakefile +13 -3
  15. data/benchmark/enabled_ips.rb +10 -0
  16. data/benchmark/enabled_multiple_actors_ips.rb +20 -0
  17. data/benchmark/enabled_profile.rb +20 -0
  18. data/benchmark/instrumentation_ips.rb +21 -0
  19. data/benchmark/typecast_ips.rb +27 -0
  20. data/docker-compose.yml +37 -34
  21. data/docs/DockerCompose.md +0 -1
  22. data/docs/README.md +1 -0
  23. data/docs/images/banner.jpg +0 -0
  24. data/docs/images/flipper_cloud.png +0 -0
  25. data/examples/api/basic.ru +18 -0
  26. data/examples/api/custom_memoized.ru +36 -0
  27. data/examples/api/memoized.ru +42 -0
  28. data/examples/basic.rb +1 -12
  29. data/examples/cloud/app.ru +12 -0
  30. data/examples/cloud/backoff_policy.rb +13 -0
  31. data/examples/cloud/basic.rb +22 -0
  32. data/examples/cloud/cloud_setup.rb +20 -0
  33. data/examples/cloud/forked.rb +36 -0
  34. data/examples/cloud/import.rb +17 -0
  35. data/examples/cloud/poll_interval/README.md +111 -0
  36. data/examples/cloud/poll_interval/client.rb +108 -0
  37. data/examples/cloud/poll_interval/server.rb +98 -0
  38. data/examples/cloud/threaded.rb +33 -0
  39. data/examples/configuring_default.rb +2 -5
  40. data/examples/dsl.rb +10 -35
  41. data/examples/enabled_for_actor.rb +10 -15
  42. data/examples/expressions.rb +237 -0
  43. data/examples/group.rb +3 -6
  44. data/examples/group_dynamic_lookup.rb +5 -19
  45. data/examples/group_with_members.rb +4 -14
  46. data/examples/importing.rb +1 -1
  47. data/examples/individual_actor.rb +2 -5
  48. data/examples/instrumentation.rb +2 -2
  49. data/examples/instrumentation_last_accessed_at.rb +38 -0
  50. data/examples/memoizing.rb +35 -0
  51. data/examples/mirroring.rb +59 -0
  52. data/examples/percentage_of_actors.rb +6 -16
  53. data/examples/percentage_of_actors_enabled_check.rb +7 -10
  54. data/examples/percentage_of_actors_group.rb +5 -18
  55. data/examples/percentage_of_time.rb +3 -6
  56. data/examples/strict.rb +18 -0
  57. data/exe/flipper +5 -0
  58. data/flipper-cloud.gemspec +19 -0
  59. data/flipper.gemspec +10 -7
  60. data/lib/flipper/actor.rb +10 -3
  61. data/lib/flipper/adapter.rb +50 -8
  62. data/lib/flipper/adapter_builder.rb +44 -0
  63. data/lib/flipper/adapters/actor_limit.rb +54 -0
  64. data/lib/flipper/adapters/cache_base.rb +161 -0
  65. data/lib/flipper/adapters/dual_write.rb +63 -0
  66. data/lib/flipper/adapters/failover.rb +85 -0
  67. data/lib/flipper/adapters/failsafe.rb +72 -0
  68. data/lib/flipper/adapters/http/client.rb +64 -7
  69. data/lib/flipper/adapters/http/error.rb +19 -1
  70. data/lib/flipper/adapters/http.rb +97 -43
  71. data/lib/flipper/adapters/instrumented.rb +47 -26
  72. data/lib/flipper/adapters/memoizable.rb +44 -40
  73. data/lib/flipper/adapters/memory.rb +75 -111
  74. data/lib/flipper/adapters/operation_logger.rb +22 -78
  75. data/lib/flipper/adapters/poll/poller.rb +2 -0
  76. data/lib/flipper/adapters/poll.rb +52 -0
  77. data/lib/flipper/adapters/pstore.rb +27 -17
  78. data/lib/flipper/adapters/read_only.rb +8 -41
  79. data/lib/flipper/adapters/strict.rb +45 -0
  80. data/lib/flipper/adapters/sync/feature_synchronizer.rb +14 -1
  81. data/lib/flipper/adapters/sync/interval_synchronizer.rb +2 -7
  82. data/lib/flipper/adapters/sync/synchronizer.rb +13 -6
  83. data/lib/flipper/adapters/sync.rb +23 -29
  84. data/lib/flipper/adapters/wrapper.rb +54 -0
  85. data/lib/flipper/cli.rb +314 -0
  86. data/lib/flipper/cloud/configuration.rb +271 -0
  87. data/lib/flipper/cloud/dsl.rb +27 -0
  88. data/lib/flipper/cloud/message_verifier.rb +95 -0
  89. data/lib/flipper/cloud/middleware.rb +63 -0
  90. data/lib/flipper/cloud/migrate.rb +71 -0
  91. data/lib/flipper/cloud/routes.rb +14 -0
  92. data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
  93. data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
  94. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  95. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  96. data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
  97. data/lib/flipper/cloud/telemetry.rb +191 -0
  98. data/lib/flipper/cloud.rb +54 -0
  99. data/lib/flipper/configuration.rb +54 -7
  100. data/lib/flipper/dsl.rb +58 -47
  101. data/lib/flipper/engine.rb +102 -0
  102. data/lib/flipper/errors.rb +3 -21
  103. data/lib/flipper/export.rb +24 -0
  104. data/lib/flipper/exporter.rb +17 -0
  105. data/lib/flipper/exporters/json/export.rb +32 -0
  106. data/lib/flipper/exporters/json/v1.rb +33 -0
  107. data/lib/flipper/expression/builder.rb +73 -0
  108. data/lib/flipper/expression/constant.rb +25 -0
  109. data/lib/flipper/expression.rb +71 -0
  110. data/lib/flipper/expressions/all.rb +9 -0
  111. data/lib/flipper/expressions/any.rb +9 -0
  112. data/lib/flipper/expressions/boolean.rb +9 -0
  113. data/lib/flipper/expressions/comparable.rb +13 -0
  114. data/lib/flipper/expressions/equal.rb +9 -0
  115. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  116. data/lib/flipper/expressions/greater_than.rb +9 -0
  117. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  118. data/lib/flipper/expressions/less_than.rb +9 -0
  119. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  120. data/lib/flipper/expressions/not_equal.rb +9 -0
  121. data/lib/flipper/expressions/now.rb +9 -0
  122. data/lib/flipper/expressions/number.rb +9 -0
  123. data/lib/flipper/expressions/percentage.rb +9 -0
  124. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  125. data/lib/flipper/expressions/property.rb +9 -0
  126. data/lib/flipper/expressions/random.rb +9 -0
  127. data/lib/flipper/expressions/string.rb +9 -0
  128. data/lib/flipper/expressions/time.rb +16 -0
  129. data/lib/flipper/feature.rb +95 -28
  130. data/lib/flipper/feature_check_context.rb +10 -6
  131. data/lib/flipper/gate.rb +13 -11
  132. data/lib/flipper/gate_values.rb +5 -18
  133. data/lib/flipper/gates/actor.rb +10 -17
  134. data/lib/flipper/gates/boolean.rb +1 -1
  135. data/lib/flipper/gates/expression.rb +75 -0
  136. data/lib/flipper/gates/group.rb +5 -7
  137. data/lib/flipper/gates/percentage_of_actors.rb +10 -13
  138. data/lib/flipper/gates/percentage_of_time.rb +1 -2
  139. data/lib/flipper/identifier.rb +17 -0
  140. data/lib/flipper/instrumentation/log_subscriber.rb +35 -8
  141. data/lib/flipper/instrumentation/statsd.rb +4 -2
  142. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  143. data/lib/flipper/instrumentation/subscriber.rb +8 -5
  144. data/lib/flipper/instrumenters/memory.rb +6 -2
  145. data/lib/flipper/metadata.rb +8 -1
  146. data/lib/flipper/middleware/memoizer.rb +46 -27
  147. data/lib/flipper/middleware/setup_env.rb +13 -3
  148. data/lib/flipper/model/active_record.rb +23 -0
  149. data/lib/flipper/poller.rb +157 -0
  150. data/lib/flipper/serializers/gzip.rb +22 -0
  151. data/lib/flipper/serializers/json.rb +17 -0
  152. data/lib/flipper/spec/shared_adapter_specs.rb +122 -56
  153. data/lib/flipper/test/shared_adapter_test.rb +120 -52
  154. data/lib/flipper/test_help.rb +43 -0
  155. data/lib/flipper/typecast.rb +59 -18
  156. data/lib/flipper/types/actor.rb +19 -13
  157. data/lib/flipper/types/group.rb +12 -5
  158. data/lib/flipper/types/percentage.rb +1 -1
  159. data/lib/flipper/version.rb +11 -1
  160. data/lib/flipper.rb +71 -12
  161. data/lib/generators/flipper/setup_generator.rb +68 -0
  162. data/lib/generators/flipper/templates/initializer.rb +45 -0
  163. data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
  164. data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
  165. data/lib/generators/flipper/update_generator.rb +35 -0
  166. data/package-lock.json +41 -0
  167. data/package.json +10 -0
  168. data/spec/fixtures/environment.rb +1 -0
  169. data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
  170. data/spec/flipper/actor_spec.rb +10 -2
  171. data/spec/flipper/adapter_builder_spec.rb +72 -0
  172. data/spec/flipper/adapter_spec.rb +52 -6
  173. data/spec/flipper/adapters/actor_limit_spec.rb +75 -0
  174. data/spec/flipper/adapters/dual_write_spec.rb +82 -0
  175. data/spec/flipper/adapters/failover_spec.rb +141 -0
  176. data/spec/flipper/adapters/failsafe_spec.rb +58 -0
  177. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  178. data/spec/flipper/adapters/http_spec.rb +402 -65
  179. data/spec/flipper/adapters/instrumented_spec.rb +31 -13
  180. data/spec/flipper/adapters/memoizable_spec.rb +51 -33
  181. data/spec/flipper/adapters/memory_spec.rb +33 -5
  182. data/spec/flipper/adapters/operation_logger_spec.rb +38 -12
  183. data/spec/flipper/adapters/poll_spec.rb +41 -0
  184. data/spec/flipper/adapters/pstore_spec.rb +0 -2
  185. data/spec/flipper/adapters/read_only_spec.rb +32 -18
  186. data/spec/flipper/adapters/strict_spec.rb +64 -0
  187. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +39 -1
  188. data/spec/flipper/adapters/sync/interval_synchronizer_spec.rb +4 -5
  189. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -1
  190. data/spec/flipper/adapters/sync_spec.rb +17 -6
  191. data/spec/flipper/cli_spec.rb +217 -0
  192. data/spec/flipper/cloud/configuration_spec.rb +257 -0
  193. data/spec/flipper/cloud/dsl_spec.rb +90 -0
  194. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  195. data/spec/flipper/cloud/middleware_spec.rb +307 -0
  196. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  197. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
  198. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  199. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  200. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  201. data/spec/flipper/cloud/telemetry_spec.rb +208 -0
  202. data/spec/flipper/cloud_spec.rb +186 -0
  203. data/spec/flipper/configuration_spec.rb +37 -3
  204. data/spec/flipper/dsl_spec.rb +67 -80
  205. data/spec/flipper/engine_spec.rb +374 -0
  206. data/spec/flipper/export_spec.rb +13 -0
  207. data/spec/flipper/exporter_spec.rb +16 -0
  208. data/spec/flipper/exporters/json/export_spec.rb +60 -0
  209. data/spec/flipper/exporters/json/v1_spec.rb +33 -0
  210. data/spec/flipper/expression/builder_spec.rb +248 -0
  211. data/spec/flipper/expression_spec.rb +188 -0
  212. data/spec/flipper/expressions/all_spec.rb +15 -0
  213. data/spec/flipper/expressions/any_spec.rb +15 -0
  214. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  215. data/spec/flipper/expressions/equal_spec.rb +24 -0
  216. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  217. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  218. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  219. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  220. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  221. data/spec/flipper/expressions/now_spec.rb +11 -0
  222. data/spec/flipper/expressions/number_spec.rb +21 -0
  223. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  224. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  225. data/spec/flipper/expressions/property_spec.rb +13 -0
  226. data/spec/flipper/expressions/random_spec.rb +9 -0
  227. data/spec/flipper/expressions/string_spec.rb +11 -0
  228. data/spec/flipper/expressions/time_spec.rb +29 -0
  229. data/spec/flipper/feature_check_context_spec.rb +18 -20
  230. data/spec/flipper/feature_spec.rb +461 -48
  231. data/spec/flipper/gate_spec.rb +0 -2
  232. data/spec/flipper/gate_values_spec.rb +2 -34
  233. data/spec/flipper/gates/actor_spec.rb +0 -2
  234. data/spec/flipper/gates/boolean_spec.rb +1 -3
  235. data/spec/flipper/gates/expression_spec.rb +190 -0
  236. data/spec/flipper/gates/group_spec.rb +2 -5
  237. data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -7
  238. data/spec/flipper/gates/percentage_of_time_spec.rb +2 -4
  239. data/spec/flipper/identifier_spec.rb +12 -0
  240. data/spec/flipper/instrumentation/log_subscriber_spec.rb +24 -7
  241. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +26 -3
  242. data/spec/flipper/instrumenters/memory_spec.rb +18 -1
  243. data/spec/flipper/instrumenters/noop_spec.rb +14 -8
  244. data/spec/flipper/middleware/memoizer_spec.rb +199 -62
  245. data/spec/flipper/middleware/setup_env_spec.rb +23 -5
  246. data/spec/flipper/model/active_record_spec.rb +72 -0
  247. data/spec/flipper/poller_spec.rb +390 -0
  248. data/spec/flipper/registry_spec.rb +0 -1
  249. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  250. data/spec/flipper/serializers/json_spec.rb +13 -0
  251. data/spec/flipper/typecast_spec.rb +121 -7
  252. data/spec/flipper/types/actor_spec.rb +63 -47
  253. data/spec/flipper/types/boolean_spec.rb +0 -1
  254. data/spec/flipper/types/group_spec.rb +24 -3
  255. data/spec/flipper/types/percentage_of_actors_spec.rb +0 -1
  256. data/spec/flipper/types/percentage_of_time_spec.rb +0 -1
  257. data/spec/flipper/types/percentage_spec.rb +0 -1
  258. data/spec/{integration_spec.rb → flipper_integration_spec.rb} +301 -59
  259. data/spec/flipper_spec.rb +123 -29
  260. data/spec/{helper.rb → spec_helper.rb} +23 -21
  261. data/spec/support/actor_names.yml +1 -0
  262. data/spec/support/descriptions.yml +1 -0
  263. data/spec/support/fail_on_output.rb +8 -0
  264. data/spec/support/fake_backoff_policy.rb +15 -0
  265. data/spec/support/skippable.rb +18 -0
  266. data/spec/support/spec_helpers.rb +53 -6
  267. data/test/adapters/actor_limit_test.rb +20 -0
  268. data/test/test_helper.rb +2 -1
  269. data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
  270. data/test_rails/generators/flipper/update_generator_test.rb +96 -0
  271. data/test_rails/helper.rb +31 -0
  272. data/test_rails/system/test_help_test.rb +52 -0
  273. metadata +200 -82
  274. data/.rubocop.yml +0 -54
  275. data/.rubocop_todo.yml +0 -199
  276. data/docs/Adapters.md +0 -124
  277. data/docs/Caveats.md +0 -4
  278. data/docs/Gates.md +0 -167
  279. data/docs/Instrumentation.md +0 -27
  280. data/docs/Optimization.md +0 -114
  281. data/docs/api/README.md +0 -849
  282. data/docs/http/README.md +0 -35
  283. data/docs/read-only/README.md +0 -21
  284. data/examples/example_setup.rb +0 -8
  285. data/test/helper.rb +0 -11
@@ -0,0 +1,314 @@
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 '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
+
138
+ command 'help' do |c|
139
+ c.load_environment = false
140
+ c.action do |command = nil|
141
+ ui.info command ? @commands[command].help : help
142
+ end
143
+ end
144
+
145
+ on_tail('-r path', "The path to load your application. Default: #{@require}") do |path|
146
+ @require = path
147
+ end
148
+
149
+ # Options available on all commands
150
+ on_tail('-h', '--help', 'Print help message') do
151
+ ui.info help
152
+ exit
153
+ end
154
+
155
+ # Set help documentation
156
+ self.banner = "Usage: #{program_name} [options] <command>"
157
+ separator ""
158
+ separator "Commands:"
159
+
160
+ pad = @commands.keys.map(&:length).max + 2
161
+ @commands.each do |name, command|
162
+ separator " #{name.to_s.ljust(pad, " ")} #{command.description}" if command.description
163
+ end
164
+
165
+ separator ""
166
+ separator "Options:"
167
+ end
168
+
169
+ def run(argv)
170
+ command, *args = order(argv)
171
+
172
+ if @commands[command]
173
+ load_environment! if @commands[command].load_environment
174
+ @commands[command].run(args)
175
+ else
176
+ ui.info help
177
+
178
+ if command
179
+ ui.error "Unknown command: #{command}"
180
+ exit 1
181
+ end
182
+ end
183
+ rescue OptionParser::InvalidOption => e
184
+ ui.error e.message
185
+ exit 1
186
+ end
187
+
188
+ # Helper method to define a new command
189
+ def command(name, &block)
190
+ @commands[name] = Command.new(program_name: "#{program_name} #{name}")
191
+ block.call(@commands[name])
192
+ end
193
+
194
+ def load_environment!
195
+ ENV["FLIPPER_CLOUD_LOGGING_ENABLED"] ||= "false"
196
+ require File.expand_path(@require)
197
+ # Ensure all of flipper gets loaded if it hasn't already.
198
+ require 'flipper'
199
+ rescue LoadError => e
200
+ ui.error e.message
201
+ exit 1
202
+ end
203
+
204
+ def feature_summary(features)
205
+ features = Array(features)
206
+ padding = features.map { |f| f.key.to_s.length }.max
207
+
208
+ features.map do |feature|
209
+ summary = case feature.state
210
+ when :on
211
+ colorize("⏺ enabled", [:GREEN])
212
+ when :off
213
+ "⦸ disabled"
214
+ else
215
+ "#{colorize("◯ enabled", [:YELLOW])} for " + feature.enabled_gates.map do |gate|
216
+ case gate.name
217
+ when :actor
218
+ pluralize feature.actors_value.size, 'actor', 'actors'
219
+ when :group
220
+ pluralize feature.groups_value.size, 'group', 'groups'
221
+ when :percentage_of_actors
222
+ "#{feature.percentage_of_actors_value}% of actors"
223
+ when :percentage_of_time
224
+ "#{feature.percentage_of_time_value}% of time"
225
+ when :expression
226
+ "an expression"
227
+ end
228
+ end.join(', ')
229
+ end
230
+
231
+ colorize("%-#{padding}s" % feature.key, [:BOLD, :WHITE]) + " is #{summary}"
232
+ end.join("\n")
233
+ end
234
+
235
+ def feature_details(feature)
236
+ summary = case feature.state
237
+ when :on
238
+ colorize("⏺ enabled", [:GREEN])
239
+ when :off
240
+ "⦸ disabled"
241
+ else
242
+ lines = feature.enabled_gates.map do |gate|
243
+ case gate.name
244
+ when :actor
245
+ [ pluralize(feature.actors_value.size, 'actor', 'actors') ] +
246
+ feature.actors_value.map { |actor| "- #{actor}" }
247
+ when :group
248
+ [ pluralize(feature.groups_value.size, 'group', 'groups') ] +
249
+ feature.groups_value.map { |group| " - #{group}" }
250
+ when :percentage_of_actors
251
+ "#{feature.percentage_of_actors_value}% of actors"
252
+ when :percentage_of_time
253
+ "#{feature.percentage_of_time_value}% of time"
254
+ when :expression
255
+ json = indent(JSON.pretty_generate(feature.expression_value), 2)
256
+ "the expression: \n#{colorize(json, [:MAGENTA])}"
257
+ end
258
+ end
259
+
260
+ "#{colorize("◯ conditionally enabled", [:YELLOW])} for:\n" +
261
+ indent(lines.flatten.join("\n"), 2)
262
+ end
263
+
264
+ "#{colorize(feature.key, [:BOLD, :WHITE])} is #{summary}"
265
+ end
266
+
267
+ def pluralize(count, singular, plural)
268
+ "#{count} #{count == 1 ? singular : plural}"
269
+ end
270
+
271
+ def colorize(text, colors)
272
+ ui.add_color(text, *colors)
273
+ end
274
+
275
+ def ui
276
+ @ui ||= Bundler::UI::Shell.new.tap do |ui|
277
+ ui.shell = shell
278
+ end
279
+ end
280
+
281
+ def indent(text, spaces)
282
+ text.gsub(/^/, " " * spaces)
283
+ end
284
+
285
+ # Redirect the shell's output to the given stdout and stderr streams
286
+ module ShellOutput
287
+ attr_reader :stdout, :stderr
288
+
289
+ def redirect(stdout: $stdout, stderr: $stderr)
290
+ @stdout, @stderr = stdout, stderr
291
+ end
292
+ end
293
+
294
+ class Command < OptionParser
295
+ attr_accessor :description, :load_environment
296
+
297
+ def initialize(program_name: nil)
298
+ super()
299
+ @program_name = program_name
300
+ @load_environment = true
301
+ @action = lambda { }
302
+ end
303
+
304
+ def run(argv)
305
+ # Parse argv and call action with arguments
306
+ @action.call(*permute(argv))
307
+ end
308
+
309
+ def action(&block)
310
+ @action = block
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,271 @@
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(cache_bust: false)
114
+ Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
115
+ instrumenter: instrumenter,
116
+ cache_bust: cache_bust,
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
+ def instrument(name, payload = {}, &block)
139
+ instrumenter.instrument(name, payload, &block)
140
+ end
141
+
142
+ private
143
+
144
+ def app_adapter
145
+ Flipper::Adapters::DualWrite.new(poll_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_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
211
+ end
212
+
213
+ def setup_adapter(options)
214
+ set_option :local_adapter, options, default: -> { Adapters::Memory.new }, from_env: false
215
+ @adapter_block = ->(adapter) { adapter }
216
+ end
217
+
218
+ def setup_telemetry(options)
219
+ # Needs to be after url and token assignments because they are used for
220
+ # uniqueness in Telemetry.instance_for.
221
+ set_option :telemetry, options, from_env: false, default: -> {
222
+ Telemetry.instance_for(self)
223
+ }
224
+
225
+ set_option :telemetry_enabled, options, default: true, typecast: :boolean
226
+ instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
227
+ @instrumenter = if telemetry_enabled
228
+ Telemetry::Instrumenter.new(self, instrumenter)
229
+ else
230
+ instrumenter
231
+ end
232
+ end
233
+
234
+ # Internal: Super helper for defining an option that can be set via
235
+ # options hash or ENV with defaults, typecasting and minimums.
236
+ def set_option(name, options, default: nil, typecast: nil, minimum: nil, from_env: true, required: false)
237
+ env_var = "FLIPPER_CLOUD_#{name.to_s.upcase}"
238
+ value = options.fetch(name) {
239
+ default_value = default.respond_to?(:call) ? default.call : default
240
+ if from_env
241
+ ENV.fetch(env_var, default_value)
242
+ else
243
+ default_value
244
+ end
245
+ }
246
+ value = Flipper::Typecast.send("to_#{typecast}", value) if typecast
247
+ send("#{name}=", value)
248
+ enforce_minimum(name, minimum) if minimum
249
+
250
+ if required
251
+ option_value = send(name)
252
+ if option_value.nil? || option_value.empty?
253
+ message = String.new("Flipper::Cloud #{name} is missing. Please ")
254
+ message << "set #{env_var} or " if from_env
255
+ message << "provide #{name} (e.g. Flipper::Cloud.new(#{name}: value))."
256
+ raise ArgumentError, message
257
+ end
258
+ end
259
+ end
260
+
261
+ # Enforce minimum interval for tasks that run on a timer.
262
+ def enforce_minimum(name, minimum)
263
+ provided = send(name)
264
+ if provided && provided < minimum
265
+ warn "Flipper::Cloud##{name} must be at least #{minimum} seconds but was #{provided}. Using #{minimum} seconds."
266
+ send(:instance_variable_set, "@#{name}", minimum)
267
+ end
268
+ end
269
+ end
270
+ end
271
+ 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(**kwargs)
14
+ @cloud_configuration.sync(**kwargs)
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