magick-feature-flags 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +37 -0
- data/app/controllers/magick/adminui/features_controller.rb +37 -9
- data/app/controllers/magick/adminui/stats_controller.rb +10 -2
- data/lib/generators/magick/install/templates/magick.rb +18 -0
- data/lib/magick/adapters/base.rb +7 -3
- data/lib/magick/adapters/memory.rb +2 -0
- data/lib/magick/adapters/redis.rb +13 -2
- data/lib/magick/adapters/registry.rb +113 -12
- data/lib/magick/admin_ui/helpers.rb +21 -16
- data/lib/magick/audit_log.rb +16 -5
- data/lib/magick/config.rb +21 -1
- data/lib/magick/export_import.rb +128 -37
- data/lib/magick/feature.rb +38 -6
- data/lib/magick/log_safe.rb +22 -0
- data/lib/magick/performance_metrics.rb +24 -4
- data/lib/magick/rails/railtie.rb +17 -0
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +17 -13
- data/lib/magick.rb +40 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3df9cccb3e3478e71402fa478d4a70401d0150290fdca89a6a47df7fbd901794
|
|
4
|
+
data.tar.gz: 929990ca6220cebb2e9215a1993df11fa25a71dbf620029a176db1c8b50edf48
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 225fdbbed5820df1ca9cb7f0283172b57ab5a3c0a26639fc6d11cef9553f80cc35402753878469db7470ef90ac691431daf399cac1b1cba26d23395ce5776f8c
|
|
7
|
+
data.tar.gz: da03312e98129cc04bc0ba63dfc06e67e171406e35fa4e46f9ff73c494a0596ef4e1e65868bab0a0ad7f43f05bbf449a81d9ac1b25eae5a0d40e1ba6245f4e44
|
data/README.md
CHANGED
|
@@ -888,6 +888,43 @@ end
|
|
|
888
888
|
- `:inactive` - Feature is disabled for everyone
|
|
889
889
|
- `:deprecated` - Feature is deprecated (can be enabled with `allow_deprecated: true` in context)
|
|
890
890
|
|
|
891
|
+
## Graceful Shutdown
|
|
892
|
+
|
|
893
|
+
Magick starts a background Redis Pub/Sub subscriber thread for cross-process
|
|
894
|
+
cache invalidation and an asynchronous metrics processor. Both must be
|
|
895
|
+
stopped before the host process exits or Puma's graceful-stop will block on
|
|
896
|
+
the still-running `Redis#subscribe` call.
|
|
897
|
+
|
|
898
|
+
The Railtie registers an `at_exit` hook that calls `Magick.shutdown!`
|
|
899
|
+
automatically, so most Rails apps don't need to do anything. In long-running
|
|
900
|
+
non-Rails processes (rake tasks, CLI tools) call it explicitly:
|
|
901
|
+
|
|
902
|
+
```ruby
|
|
903
|
+
Magick.shutdown! # default 5 second join timeout
|
|
904
|
+
Magick.shutdown!(timeout: 1) # more aggressive
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
Fork-based deployments (Puma workers with `preload_app`, Unicorn) are handled
|
|
908
|
+
automatically: `config.to_prepare` calls `ensure_subscriber!` and
|
|
909
|
+
`ensure_async_processor!` on every prepare cycle, so children that inherit
|
|
910
|
+
stale parent threads start fresh. No action required from the host app.
|
|
911
|
+
|
|
912
|
+
## Admin UI Security
|
|
913
|
+
|
|
914
|
+
The Admin UI is CSRF-protected out of the box (`protect_from_forgery with:
|
|
915
|
+
:exception`) and 404s on unknown feature IDs instead of auto-creating
|
|
916
|
+
features from user-controlled `params[:id]`.
|
|
917
|
+
|
|
918
|
+
Authentication is **opt-in** — if `Magick::AdminUI.config.require_role` is
|
|
919
|
+
left `nil` the UI is reachable by anyone who can hit its routes. Always set
|
|
920
|
+
it behind your app's auth:
|
|
921
|
+
|
|
922
|
+
```ruby
|
|
923
|
+
Magick::AdminUI.configure do |c|
|
|
924
|
+
c.require_role = ->(controller) { controller.current_user&.admin? }
|
|
925
|
+
end
|
|
926
|
+
```
|
|
927
|
+
|
|
891
928
|
## Testing
|
|
892
929
|
|
|
893
930
|
Use the testing helpers in your RSpec tests:
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
module Magick
|
|
4
4
|
module AdminUI
|
|
5
5
|
class FeaturesController < ActionController::Base
|
|
6
|
+
# Inheriting ActionController::Base does NOT bring in CSRF protection.
|
|
7
|
+
# Include RequestForgeryProtection + explicit protect_from_forgery so
|
|
8
|
+
# cross-site form submissions cannot toggle flags behind a logged-in
|
|
9
|
+
# admin's back.
|
|
10
|
+
include ::ActionController::RequestForgeryProtection
|
|
11
|
+
protect_from_forgery with: :exception
|
|
12
|
+
|
|
6
13
|
# Include route helpers so views can use magick_admin_ui.* helpers
|
|
7
14
|
include Magick::AdminUI::Engine.routes.url_helpers
|
|
8
15
|
layout 'application'
|
|
@@ -120,6 +127,10 @@ module Magick
|
|
|
120
127
|
def update_targeting
|
|
121
128
|
# Handle targeting updates from form
|
|
122
129
|
targeting_params = params[:targeting] || {}
|
|
130
|
+
unless hash_like?(targeting_params)
|
|
131
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid targeting payload.'
|
|
132
|
+
return
|
|
133
|
+
end
|
|
123
134
|
|
|
124
135
|
# Ensure we're using the registered feature instance
|
|
125
136
|
feature_name = @feature.name.to_s
|
|
@@ -266,13 +277,18 @@ module Magick
|
|
|
266
277
|
|
|
267
278
|
redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Targeting updated successfully'
|
|
268
279
|
rescue StandardError => e
|
|
269
|
-
Rails.logger.error "Magick: Error updating targeting: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
|
|
270
|
-
redirect_to magick_admin_ui.feature_path(@feature.name), alert:
|
|
280
|
+
Rails.logger.error "Magick: Error updating targeting for #{@feature.name}: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
|
|
281
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update targeting — see server logs for details.'
|
|
271
282
|
end
|
|
272
283
|
|
|
273
284
|
def update_variants
|
|
274
285
|
variants_data = []
|
|
275
286
|
|
|
287
|
+
if params[:variants].present? && !hash_like?(params[:variants])
|
|
288
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid variants payload.'
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
|
|
276
292
|
if params[:variants].present?
|
|
277
293
|
params[:variants].each do |_index, variant_params|
|
|
278
294
|
next if variant_params[:name].blank?
|
|
@@ -295,8 +311,8 @@ module Magick
|
|
|
295
311
|
|
|
296
312
|
redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Variants updated successfully'
|
|
297
313
|
rescue StandardError => e
|
|
298
|
-
Rails.logger.error "Magick: Error updating variants: #{e.message}" if defined?(Rails)
|
|
299
|
-
redirect_to magick_admin_ui.feature_path(@feature.name), alert:
|
|
314
|
+
Rails.logger.error "Magick: Error updating variants for #{@feature.name}: #{e.class}: #{e.message}" if defined?(Rails)
|
|
315
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update variants — see server logs for details.'
|
|
300
316
|
end
|
|
301
317
|
|
|
302
318
|
private
|
|
@@ -313,13 +329,25 @@ module Magick
|
|
|
313
329
|
end
|
|
314
330
|
end
|
|
315
331
|
|
|
332
|
+
# A hash-like payload is either a raw Hash or an
|
|
333
|
+
# ActionController::Parameters (both respond to :each with key/value).
|
|
334
|
+
# Rejecting anything else lets us give a 400-style redirect instead of
|
|
335
|
+
# a 500 with a NoMethodError stack trace.
|
|
336
|
+
def hash_like?(obj)
|
|
337
|
+
obj.is_a?(Hash) || (defined?(ActionController::Parameters) && obj.is_a?(ActionController::Parameters))
|
|
338
|
+
end
|
|
339
|
+
|
|
316
340
|
def set_feature
|
|
317
341
|
feature_name = params[:id].to_s
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
#
|
|
322
|
-
@feature = Magick.features[feature_name]
|
|
342
|
+
# Do NOT fall back to Magick[feature_name] — that would lazily create
|
|
343
|
+
# and persist a brand-new feature from user-controlled input, letting
|
|
344
|
+
# an attacker (or even a stray crawler) pollute Redis/AR with arbitrary
|
|
345
|
+
# keys. Look up only registered features; 404 otherwise.
|
|
346
|
+
@feature = Magick.features[feature_name]
|
|
347
|
+
return if @feature
|
|
348
|
+
|
|
349
|
+
redirect_to magick_admin_ui.features_path, alert: 'Feature not found'
|
|
350
|
+
nil
|
|
323
351
|
end
|
|
324
352
|
end
|
|
325
353
|
end
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Magick
|
|
4
4
|
module AdminUI
|
|
5
5
|
class StatsController < ActionController::Base
|
|
6
|
+
include ::ActionController::RequestForgeryProtection
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
|
|
6
9
|
include Magick::AdminUI::Engine.routes.url_helpers
|
|
7
10
|
layout 'application'
|
|
8
11
|
|
|
@@ -13,8 +16,13 @@ module Magick
|
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def show
|
|
16
|
-
|
|
17
|
-
@
|
|
19
|
+
feature_name = params[:id].to_s
|
|
20
|
+
@feature = Magick.features[feature_name]
|
|
21
|
+
unless @feature
|
|
22
|
+
head :not_found
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
@stats = Magick.feature_stats(feature_name.to_sym) || {}
|
|
18
26
|
end
|
|
19
27
|
end
|
|
20
28
|
end
|
|
@@ -32,3 +32,21 @@ Magick.configure do
|
|
|
32
32
|
# Enable deprecation warnings in logs
|
|
33
33
|
warn_on_deprecated enabled: true
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# Admin UI configuration (optional).
|
|
37
|
+
# The Admin UI is CSRF-protected and 404s on unknown feature IDs, BUT
|
|
38
|
+
# authentication is opt-in — if you mount the engine without configuring
|
|
39
|
+
# `require_role`, the UI will be reachable by anyone who can hit its routes.
|
|
40
|
+
# Always wire it to your host app's auth layer:
|
|
41
|
+
#
|
|
42
|
+
# Magick::AdminUI.configure do |c|
|
|
43
|
+
# c.require_role = ->(controller) { controller.current_user&.admin? }
|
|
44
|
+
# c.available_roles = %w[admin manager]
|
|
45
|
+
# c.available_tags = -> { Tag.pluck(:name) }
|
|
46
|
+
# end
|
|
47
|
+
|
|
48
|
+
# Graceful shutdown is handled automatically in Rails via an at_exit hook
|
|
49
|
+
# wired by the Railtie. In long-running non-Rails processes (rake tasks,
|
|
50
|
+
# CLI tools), call `Magick.shutdown!` explicitly before exit so the
|
|
51
|
+
# Redis Pub/Sub subscriber and async metrics thread terminate cleanly.
|
|
52
|
+
|
data/lib/magick/adapters/base.rb
CHANGED
|
@@ -33,9 +33,13 @@ module Magick
|
|
|
33
33
|
{}
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
# Bulk set multiple keys for a feature in one call
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
# Bulk set multiple keys for a feature in one call.
|
|
37
|
+
# Subclasses MUST implement this — a no-op default silently drops
|
|
38
|
+
# bulk writes, which is why this used to cause hard-to-diagnose lost
|
|
39
|
+
# updates for custom adapters (audit P2-Co6).
|
|
40
|
+
def set_all_data(feature_name, data_hash)
|
|
41
|
+
raise NotImplementedError,
|
|
42
|
+
"#{self.class} must implement #set_all_data (feature=#{feature_name}, keys=#{data_hash.keys.inspect})"
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
end
|
|
@@ -31,6 +31,7 @@ module Magick
|
|
|
31
31
|
|
|
32
32
|
def set(feature_name, key, value)
|
|
33
33
|
mutex.synchronize do
|
|
34
|
+
cleanup_expired_if_needed
|
|
34
35
|
feature_name_str = feature_name.to_s
|
|
35
36
|
store[feature_name_str] ||= {}
|
|
36
37
|
store[feature_name_str][key.to_s] = serialize_value(value)
|
|
@@ -91,6 +92,7 @@ module Magick
|
|
|
91
92
|
# Bulk set all data for a feature (used by preloading)
|
|
92
93
|
def set_all_data(feature_name, data_hash)
|
|
93
94
|
mutex.synchronize do
|
|
95
|
+
cleanup_expired_if_needed
|
|
94
96
|
feature_name_str = feature_name.to_s
|
|
95
97
|
store[feature_name_str] ||= {}
|
|
96
98
|
data_hash.each do |key, value|
|
|
@@ -103,13 +103,24 @@ module Magick
|
|
|
103
103
|
|
|
104
104
|
attr_reader :redis, :namespace
|
|
105
105
|
|
|
106
|
-
# Use SCAN instead of KEYS to avoid blocking Redis
|
|
106
|
+
# Use SCAN instead of KEYS to avoid blocking Redis.
|
|
107
|
+
# A mid-scan timeout would otherwise lose the cursor; retry once with
|
|
108
|
+
# exponential backoff before surfacing the error to the caller.
|
|
107
109
|
def scan_keys
|
|
108
110
|
pattern = "#{namespace}:*"
|
|
109
111
|
keys = []
|
|
110
112
|
cursor = '0'
|
|
113
|
+
retries = 0
|
|
111
114
|
loop do
|
|
112
|
-
|
|
115
|
+
begin
|
|
116
|
+
cursor, batch = redis.scan(cursor, match: pattern, count: 100)
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
raise if retries >= 1
|
|
119
|
+
|
|
120
|
+
retries += 1
|
|
121
|
+
sleep(0.05 * retries)
|
|
122
|
+
retry
|
|
123
|
+
end
|
|
113
124
|
keys.concat(batch)
|
|
114
125
|
break if cursor == '0'
|
|
115
126
|
end
|
|
@@ -7,6 +7,12 @@ module Magick
|
|
|
7
7
|
|
|
8
8
|
LOCAL_WRITE_TTL = 2.0 # seconds to ignore self-invalidation after a local write
|
|
9
9
|
|
|
10
|
+
# Accept only conservative feature identifiers coming off the wire.
|
|
11
|
+
# Anything outside this alphabet (newlines, spaces, Unicode punctuation,
|
|
12
|
+
# 200-char garbage) is a sign of a malformed or malicious publisher and
|
|
13
|
+
# must not be fed back into Magick.features[...] / feature.reload.
|
|
14
|
+
FEATURE_NAME_PATTERN = /\A[a-zA-Z0-9_\-.:]{1,120}\z/.freeze
|
|
15
|
+
|
|
10
16
|
def initialize(memory_adapter, redis_adapter = nil, active_record_adapter: nil, circuit_breaker: nil,
|
|
11
17
|
async: false, primary: nil)
|
|
12
18
|
@memory_adapter = memory_adapter
|
|
@@ -21,11 +27,54 @@ module Magick
|
|
|
21
27
|
@last_reload_times = {} # Track last reload time per feature for debouncing
|
|
22
28
|
@local_writes = {} # Track recent local writes to skip self-invalidation
|
|
23
29
|
@reload_mutex = Mutex.new
|
|
30
|
+
@stopping = false
|
|
31
|
+
@shutdown_mutex = Mutex.new
|
|
32
|
+
@owner_pid = Process.pid
|
|
24
33
|
# Only start Pub/Sub subscriber if Redis is available
|
|
25
34
|
# In memory-only mode, each process has isolated cache (no cross-process invalidation)
|
|
26
35
|
start_cache_invalidation_subscriber if redis_adapter
|
|
27
36
|
end
|
|
28
37
|
|
|
38
|
+
# Restart the Pub/Sub subscriber after a fork. The subscriber thread is
|
|
39
|
+
# not carried into child processes, so a worker inheriting a stale
|
|
40
|
+
# reference must re-create its own subscription. Safe to call on every
|
|
41
|
+
# request; it only does work when Process.pid changes.
|
|
42
|
+
def ensure_subscriber!
|
|
43
|
+
return if @owner_pid == Process.pid
|
|
44
|
+
|
|
45
|
+
@shutdown_mutex.synchronize do
|
|
46
|
+
return if @owner_pid == Process.pid
|
|
47
|
+
|
|
48
|
+
@subscriber_thread = nil
|
|
49
|
+
@subscriber = nil
|
|
50
|
+
@owner_pid = Process.pid
|
|
51
|
+
@stopping = false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
start_cache_invalidation_subscriber if redis_adapter
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Gracefully terminate the Pub/Sub subscriber thread and its Redis connection.
|
|
58
|
+
# Without this, Ruby/Puma shutdown waits on the blocking `subscribe` call.
|
|
59
|
+
def shutdown(timeout: 5)
|
|
60
|
+
@shutdown_mutex.synchronize do
|
|
61
|
+
return if @stopping
|
|
62
|
+
|
|
63
|
+
@stopping = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
close_subscriber_connection(@subscriber)
|
|
67
|
+
terminate_subscriber_thread(@subscriber_thread, timeout)
|
|
68
|
+
|
|
69
|
+
@subscriber = nil
|
|
70
|
+
@subscriber_thread = nil
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stopping?
|
|
75
|
+
@stopping == true
|
|
76
|
+
end
|
|
77
|
+
|
|
29
78
|
def get(feature_name, key)
|
|
30
79
|
# Try memory first (fastest) - no Redis calls needed thanks to Pub/Sub invalidation
|
|
31
80
|
value = memory_adapter.get(feature_name, key) if memory_adapter
|
|
@@ -76,11 +125,7 @@ module Magick
|
|
|
76
125
|
end
|
|
77
126
|
|
|
78
127
|
if @async && defined?(Thread)
|
|
79
|
-
|
|
80
|
-
update_redis.call
|
|
81
|
-
# Publish AFTER Redis write so other processes read fresh data
|
|
82
|
-
publish_cache_invalidation(feature_name)
|
|
83
|
-
end
|
|
128
|
+
spawn_async_write(feature_name, update_redis)
|
|
84
129
|
else
|
|
85
130
|
update_redis.call
|
|
86
131
|
publish_cache_invalidation(feature_name)
|
|
@@ -226,11 +271,7 @@ module Magick
|
|
|
226
271
|
end
|
|
227
272
|
|
|
228
273
|
if @async && defined?(Thread)
|
|
229
|
-
|
|
230
|
-
update_redis.call
|
|
231
|
-
# Publish AFTER Redis write so other processes read fresh data
|
|
232
|
-
publish_cache_invalidation(feature_name)
|
|
233
|
-
end
|
|
274
|
+
spawn_async_write(feature_name, update_redis)
|
|
234
275
|
else
|
|
235
276
|
update_redis.call
|
|
236
277
|
publish_cache_invalidation(feature_name)
|
|
@@ -288,11 +329,57 @@ module Magick
|
|
|
288
329
|
|
|
289
330
|
attr_reader :memory_adapter, :redis_adapter, :active_record_adapter, :circuit_breaker
|
|
290
331
|
|
|
332
|
+
# Signal the subscribe loop to return, then close the connection so any
|
|
333
|
+
# retry/reconnect attempt fails fast instead of sleeping for 5s.
|
|
334
|
+
def close_subscriber_connection(subscriber)
|
|
335
|
+
return unless subscriber
|
|
336
|
+
|
|
337
|
+
begin
|
|
338
|
+
subscriber.unsubscribe(CACHE_INVALIDATION_CHANNEL)
|
|
339
|
+
rescue StandardError
|
|
340
|
+
# connection may already be dead; fall through to close/kill
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
begin
|
|
344
|
+
subscriber.close
|
|
345
|
+
rescue StandardError
|
|
346
|
+
# ignore: best-effort close
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def terminate_subscriber_thread(thread, timeout)
|
|
351
|
+
return unless thread
|
|
352
|
+
return if thread.join(timeout)
|
|
353
|
+
|
|
354
|
+
thread.kill
|
|
355
|
+
thread.join(1) # give it a moment to actually unwind
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Fire-and-forget async Redis write. Wrapped so that a failure in the
|
|
359
|
+
# update or publish step is logged rather than silently killing the
|
|
360
|
+
# thread — Thread#abort_on_exception is false, which otherwise swallows
|
|
361
|
+
# the error completely.
|
|
362
|
+
def spawn_async_write(feature_name, update_redis)
|
|
363
|
+
thread = Thread.new do
|
|
364
|
+
update_redis.call
|
|
365
|
+
publish_cache_invalidation(feature_name)
|
|
366
|
+
rescue StandardError => e
|
|
367
|
+
warn "Magick: async Redis write failed for '#{Magick::LogSafe.sanitize(feature_name)}': #{e.class}: #{Magick::LogSafe.sanitize(e.message)}"
|
|
368
|
+
end
|
|
369
|
+
thread.name = "magick-async-write-#{feature_name}" if thread.respond_to?(:name=)
|
|
370
|
+
thread.abort_on_exception = false
|
|
371
|
+
thread
|
|
372
|
+
end
|
|
373
|
+
|
|
291
374
|
# Record that this process just wrote a feature, so the subscriber
|
|
292
375
|
# ignores its own Pub/Sub messages and doesn't revert the correct in-memory state.
|
|
293
376
|
def record_local_write(feature_name)
|
|
294
377
|
@reload_mutex.synchronize do
|
|
295
378
|
@local_writes[feature_name.to_s] = Time.now.to_f
|
|
379
|
+
# Also sweep stale tracking entries on the write path — a write-heavy
|
|
380
|
+
# process that rarely reads would otherwise never trigger cleanup,
|
|
381
|
+
# letting @local_writes and @last_reload_times grow unboundedly.
|
|
382
|
+
cleanup_stale_tracking_entries
|
|
296
383
|
end
|
|
297
384
|
end
|
|
298
385
|
|
|
@@ -357,6 +444,16 @@ module Magick
|
|
|
357
444
|
on.message do |_channel, feature_name|
|
|
358
445
|
feature_name_str = feature_name.to_s
|
|
359
446
|
|
|
447
|
+
# Reject malformed payloads before doing anything with them.
|
|
448
|
+
# A shared Redis DB is not a trust boundary — reject anything
|
|
449
|
+
# that isn't a plausible feature identifier.
|
|
450
|
+
unless FEATURE_NAME_PATTERN.match?(feature_name_str)
|
|
451
|
+
if defined?(Rails) && Rails.env.development?
|
|
452
|
+
warn "Magick: ignoring malformed pubsub payload (#{feature_name_str.bytesize}B)"
|
|
453
|
+
end
|
|
454
|
+
next
|
|
455
|
+
end
|
|
456
|
+
|
|
360
457
|
# Skip self-invalidation: if this process just wrote this feature,
|
|
361
458
|
# memory already has the correct value. Reloading from Redis would
|
|
362
459
|
# revert it to stale data (especially with async writes).
|
|
@@ -408,7 +505,7 @@ module Magick
|
|
|
408
505
|
end
|
|
409
506
|
|
|
410
507
|
if defined?(Rails) && Rails.env.development?
|
|
411
|
-
warn "Magick: Error processing cache invalidation for '#{feature_name}': #{e.message}"
|
|
508
|
+
warn "Magick: Error processing cache invalidation for '#{Magick::LogSafe.sanitize(feature_name)}': #{Magick::LogSafe.sanitize(e.message)}"
|
|
412
509
|
end
|
|
413
510
|
end
|
|
414
511
|
end
|
|
@@ -421,9 +518,13 @@ module Magick
|
|
|
421
518
|
(defined?(Rails) && Rails.env.test?)
|
|
422
519
|
return if is_rspec_error
|
|
423
520
|
|
|
521
|
+
# Stop cleanly during app shutdown instead of sleeping + retrying,
|
|
522
|
+
# which would keep the process alive and delay termination.
|
|
523
|
+
return if @stopping
|
|
524
|
+
|
|
424
525
|
warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
425
526
|
sleep 5
|
|
426
|
-
retry
|
|
527
|
+
retry unless @stopping
|
|
427
528
|
end
|
|
428
529
|
@subscriber_thread.abort_on_exception = false
|
|
429
530
|
end
|
|
@@ -4,28 +4,33 @@ module Magick
|
|
|
4
4
|
module AdminUI
|
|
5
5
|
module Helpers
|
|
6
6
|
def self.feature_status_badge(status)
|
|
7
|
-
case status.to_sym
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
klass = case status.to_sym
|
|
8
|
+
when :active then 'badge badge-success'
|
|
9
|
+
when :deprecated then 'badge badge-warning'
|
|
10
|
+
when :inactive then 'badge badge-danger'
|
|
11
|
+
else 'badge'
|
|
12
|
+
end
|
|
13
|
+
label = case status.to_sym
|
|
14
|
+
when :active then 'Active'
|
|
15
|
+
when :deprecated then 'Deprecated'
|
|
16
|
+
when :inactive then 'Inactive'
|
|
17
|
+
else 'Unknown'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if defined?(ActionController::Base)
|
|
21
|
+
ActionController::Base.helpers.content_tag(:span, label, class: klass)
|
|
14
22
|
else
|
|
15
|
-
|
|
23
|
+
# Fallback when Rails is not present. Label is a whitelisted literal.
|
|
24
|
+
"<span class=\"#{klass}\">#{label}</span>"
|
|
16
25
|
end
|
|
17
26
|
end
|
|
18
27
|
|
|
19
28
|
def self.feature_type_label(type)
|
|
20
29
|
case type.to_sym
|
|
21
|
-
when :boolean
|
|
22
|
-
|
|
23
|
-
when :
|
|
24
|
-
|
|
25
|
-
when :number
|
|
26
|
-
'Number'
|
|
27
|
-
else
|
|
28
|
-
type.to_s.capitalize
|
|
30
|
+
when :boolean then 'Boolean'
|
|
31
|
+
when :string then 'String'
|
|
32
|
+
when :number then 'Number'
|
|
33
|
+
else type.to_s.capitalize
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
end
|
data/lib/magick/audit_log.rb
CHANGED
|
@@ -26,20 +26,27 @@ module Magick
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
DEFAULT_MAX_ENTRIES = 10_000
|
|
30
|
+
|
|
31
|
+
def initialize(adapter = nil, max_entries: DEFAULT_MAX_ENTRIES)
|
|
30
32
|
@adapter = adapter || default_adapter
|
|
31
33
|
@logs = []
|
|
34
|
+
@max_entries = max_entries.to_i.positive? ? max_entries.to_i : DEFAULT_MAX_ENTRIES
|
|
32
35
|
@mutex = Mutex.new
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
attr_reader :max_entries
|
|
39
|
+
|
|
35
40
|
def log(feature_name, action, user_id: nil, changes: {}, metadata: {})
|
|
36
41
|
entry = Entry.new(feature_name, action, user_id: user_id, changes: changes, metadata: metadata)
|
|
37
42
|
@mutex.synchronize do
|
|
38
43
|
@logs << entry
|
|
44
|
+
# Cap in-memory ring; older entries fall out once we cross the limit.
|
|
45
|
+
# This keeps long-running processes from growing @logs unboundedly.
|
|
46
|
+
@logs.shift while @logs.size > @max_entries
|
|
39
47
|
@adapter.append(entry) if @adapter.respond_to?(:append)
|
|
40
48
|
end
|
|
41
49
|
|
|
42
|
-
# Rails 8+ event
|
|
43
50
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
44
51
|
Magick::Rails::Events.audit_logged(feature_name, action: action, user_id: user_id, changes: changes, **metadata)
|
|
45
52
|
end
|
|
@@ -48,9 +55,13 @@ module Magick
|
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
def entries(feature_name: nil, limit: 100)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
snapshot = @mutex.synchronize { @logs.dup }
|
|
59
|
+
snapshot = snapshot.select { |e| e.feature_name == feature_name.to_s } if feature_name
|
|
60
|
+
snapshot.last(limit)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def size
|
|
64
|
+
@mutex.synchronize { @logs.size }
|
|
54
65
|
end
|
|
55
66
|
|
|
56
67
|
private
|
data/lib/magick/config.rb
CHANGED
|
@@ -317,9 +317,29 @@ module Magick
|
|
|
317
317
|
config
|
|
318
318
|
end
|
|
319
319
|
|
|
320
|
+
# Load a Magick configuration DSL file by path.
|
|
321
|
+
#
|
|
322
|
+
# SECURITY: This method evaluates the file's contents as Ruby via
|
|
323
|
+
# instance_eval. Never pass a path derived from HTTP input, ENV
|
|
324
|
+
# variables, build artifacts, or any other untrusted source — doing so
|
|
325
|
+
# is remote code execution. Callers must guarantee the path points at a
|
|
326
|
+
# file that lives inside the project tree (typical use:
|
|
327
|
+
# Rails.root.join('config/features.rb')).
|
|
328
|
+
#
|
|
329
|
+
# The path is resolved with File.realpath and must be inside the
|
|
330
|
+
# current working directory. An explicit opt-in env var
|
|
331
|
+
# (MAGICK_ALLOW_CONFIG_EVAL=1) is required to load paths outside CWD.
|
|
320
332
|
def self.load_from_file(file_path)
|
|
333
|
+
resolved = File.realpath(file_path)
|
|
334
|
+
allow_outside_cwd = ENV['MAGICK_ALLOW_CONFIG_EVAL'] == '1'
|
|
335
|
+
unless allow_outside_cwd || resolved.start_with?(Dir.pwd)
|
|
336
|
+
raise SecurityError,
|
|
337
|
+
"Refusing to load Magick config from outside the project tree: #{resolved}. " \
|
|
338
|
+
'Set MAGICK_ALLOW_CONFIG_EVAL=1 to override (only if you trust the file).'
|
|
339
|
+
end
|
|
340
|
+
|
|
321
341
|
config = Config.new
|
|
322
|
-
config.instance_eval(File.read(
|
|
342
|
+
config.instance_eval(File.read(resolved), resolved)
|
|
323
343
|
config.apply!
|
|
324
344
|
config
|
|
325
345
|
end
|
data/lib/magick/export_import.rb
CHANGED
|
@@ -4,12 +4,23 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
module Magick
|
|
6
6
|
class ExportImport
|
|
7
|
+
# Hard cap on the number of features accepted by a single import call.
|
|
8
|
+
# Guards against DoS via an oversized payload and (combined with Admin UI
|
|
9
|
+
# auth) stops an attacker from using import as a flag-replacement
|
|
10
|
+
# primitive. Override with the MAGICK_MAX_IMPORT_FEATURES env var.
|
|
11
|
+
DEFAULT_MAX_IMPORT_FEATURES = 10_000
|
|
12
|
+
|
|
13
|
+
class ImportError < StandardError; end
|
|
14
|
+
|
|
15
|
+
def self.max_import_features
|
|
16
|
+
ENV.fetch('MAGICK_MAX_IMPORT_FEATURES', DEFAULT_MAX_IMPORT_FEATURES).to_i
|
|
17
|
+
end
|
|
18
|
+
|
|
7
19
|
def self.export(features_hash)
|
|
8
20
|
result = features_hash.map do |_name, feature|
|
|
9
21
|
feature.to_h
|
|
10
22
|
end
|
|
11
23
|
|
|
12
|
-
# Rails 8+ event
|
|
13
24
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
14
25
|
Magick::Rails::Events.exported(format: :hash, feature_count: result.length)
|
|
15
26
|
end
|
|
@@ -20,7 +31,6 @@ module Magick
|
|
|
20
31
|
def self.export_json(features_hash)
|
|
21
32
|
result = JSON.pretty_generate(export(features_hash))
|
|
22
33
|
|
|
23
|
-
# Rails 8+ event
|
|
24
34
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
25
35
|
Magick::Rails::Events.exported(format: :json, feature_count: features_hash.length)
|
|
26
36
|
end
|
|
@@ -31,54 +41,135 @@ module Magick
|
|
|
31
41
|
def self.import(data, adapter_registry)
|
|
32
42
|
features = {}
|
|
33
43
|
data = JSON.parse(data) if data.is_a?(String)
|
|
44
|
+
list = Array(data)
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
cap = max_import_features
|
|
47
|
+
if list.size > cap
|
|
48
|
+
raise ImportError,
|
|
49
|
+
"Magick.import: refused to import #{list.size} features; limit is #{cap}. " \
|
|
50
|
+
'Set MAGICK_MAX_IMPORT_FEATURES to override.'
|
|
51
|
+
end
|
|
38
52
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
type: (feature_data['type'] || feature_data[:type] || :boolean).to_sym,
|
|
43
|
-
status: (feature_data['status'] || feature_data[:status] || :active).to_sym,
|
|
44
|
-
default_value: feature_data['default_value'] || feature_data[:default_value],
|
|
45
|
-
description: feature_data['description'] || feature_data[:description]
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
if feature_data['value'] || feature_data[:value]
|
|
49
|
-
feature.set_value(feature_data['value'] || feature_data[:value])
|
|
53
|
+
list.each do |feature_data|
|
|
54
|
+
unless feature_data.is_a?(Hash)
|
|
55
|
+
raise ImportError, "Magick.import: each feature payload must be a Hash, got #{feature_data.class}"
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
when :group
|
|
61
|
-
feature.enable_for_group(value)
|
|
62
|
-
when :role
|
|
63
|
-
feature.enable_for_role(value)
|
|
64
|
-
when :percentage_users
|
|
65
|
-
feature.enable_percentage_of_users(value)
|
|
66
|
-
when :percentage_requests
|
|
67
|
-
feature.enable_percentage_of_requests(value)
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
58
|
+
name = fetch(feature_data, :name)
|
|
59
|
+
next unless name
|
|
60
|
+
|
|
61
|
+
feature = build_feature(name, feature_data, adapter_registry)
|
|
62
|
+
apply_value(feature, feature_data)
|
|
63
|
+
apply_targeting(feature, fetch(feature_data, :targeting) || {})
|
|
64
|
+
apply_variants(feature, fetch(feature_data, :variants) || [])
|
|
65
|
+
apply_dependencies(feature, fetch(feature_data, :dependencies) || [])
|
|
72
66
|
|
|
73
67
|
features[name.to_s] = feature
|
|
74
68
|
end
|
|
75
69
|
|
|
76
|
-
# Rails 8+ event
|
|
77
70
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
78
71
|
Magick::Rails::Events.imported(format: :json, feature_count: features.length)
|
|
79
72
|
end
|
|
80
73
|
|
|
81
74
|
features
|
|
82
75
|
end
|
|
76
|
+
|
|
77
|
+
def self.fetch(hash, key)
|
|
78
|
+
# Must not use `||` — falsy legitimate values (false, 0, "") would
|
|
79
|
+
# silently fall through to the string-key lookup (and then to nil).
|
|
80
|
+
return hash[key] if hash.key?(key)
|
|
81
|
+
|
|
82
|
+
string_key = key.to_s
|
|
83
|
+
return hash[string_key] if hash.key?(string_key)
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.build_feature(name, feature_data, adapter_registry)
|
|
89
|
+
Feature.new(
|
|
90
|
+
name,
|
|
91
|
+
adapter_registry,
|
|
92
|
+
type: (fetch(feature_data, :type) || :boolean).to_sym,
|
|
93
|
+
status: (fetch(feature_data, :status) || :active).to_sym,
|
|
94
|
+
default_value: fetch(feature_data, :default_value),
|
|
95
|
+
description: fetch(feature_data, :description),
|
|
96
|
+
display_name: fetch(feature_data, :display_name),
|
|
97
|
+
group: fetch(feature_data, :group)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.apply_value(feature, feature_data)
|
|
102
|
+
value = fetch(feature_data, :value)
|
|
103
|
+
feature.set_value(value) if !value.nil? && !(value.is_a?(String) && value.empty?)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# rubocop:disable Metrics/MethodLength
|
|
107
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
108
|
+
def self.apply_targeting(feature, targeting)
|
|
109
|
+
targeting.each do |type, values|
|
|
110
|
+
case type.to_sym
|
|
111
|
+
when :user, :users
|
|
112
|
+
Array(values).each { |v| feature.enable_for_user(v) }
|
|
113
|
+
when :excluded_users
|
|
114
|
+
Array(values).each { |v| feature.exclude_user(v) }
|
|
115
|
+
when :group, :groups
|
|
116
|
+
Array(values).each { |v| feature.enable_for_group(v) }
|
|
117
|
+
when :excluded_groups
|
|
118
|
+
Array(values).each { |v| feature.exclude_group(v) }
|
|
119
|
+
when :role, :roles
|
|
120
|
+
Array(values).each { |v| feature.enable_for_role(v) }
|
|
121
|
+
when :excluded_roles
|
|
122
|
+
Array(values).each { |v| feature.exclude_role(v) }
|
|
123
|
+
when :tag, :tags
|
|
124
|
+
Array(values).each { |v| feature.enable_for_tag(v) }
|
|
125
|
+
when :excluded_tags
|
|
126
|
+
Array(values).each { |v| feature.exclude_tag(v) }
|
|
127
|
+
when :ip_address, :ip_addresses
|
|
128
|
+
feature.enable_for_ip_addresses(Array(values))
|
|
129
|
+
when :excluded_ip_addresses
|
|
130
|
+
feature.exclude_ip_addresses(Array(values))
|
|
131
|
+
when :percentage_users
|
|
132
|
+
feature.enable_percentage_of_users(values)
|
|
133
|
+
when :percentage_requests
|
|
134
|
+
feature.enable_percentage_of_requests(values)
|
|
135
|
+
when :date_range
|
|
136
|
+
range = values.is_a?(Hash) ? values.transform_keys(&:to_sym) : values
|
|
137
|
+
feature.enable_for_date_range(range[:start], range[:end]) if range.is_a?(Hash) && range[:start] && range[:end]
|
|
138
|
+
when :custom_attributes
|
|
139
|
+
apply_custom_attributes(feature, values)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
# rubocop:enable Metrics/MethodLength
|
|
144
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
145
|
+
|
|
146
|
+
def self.apply_custom_attributes(feature, values)
|
|
147
|
+
return unless values.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
values.each do |attr, rule|
|
|
150
|
+
rule_h = rule.is_a?(Hash) ? rule.transform_keys(&:to_sym) : {}
|
|
151
|
+
next unless rule_h[:values]
|
|
152
|
+
|
|
153
|
+
feature.enable_for_custom_attribute(attr, rule_h[:values], operator: (rule_h[:operator] || :equals).to_sym)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.apply_variants(feature, variants)
|
|
158
|
+
return unless feature.respond_to?(:add_variant)
|
|
159
|
+
|
|
160
|
+
Array(variants).each do |v|
|
|
161
|
+
h = v.is_a?(Hash) ? v.transform_keys(&:to_sym) : {}
|
|
162
|
+
next unless h[:name]
|
|
163
|
+
|
|
164
|
+
feature.add_variant(h[:name], weight: h[:weight], value: h[:value])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.apply_dependencies(feature, deps)
|
|
169
|
+
list = Array(deps).map(&:to_s)
|
|
170
|
+
return if list.empty?
|
|
171
|
+
|
|
172
|
+
feature.instance_variable_set(:@dependencies, list)
|
|
173
|
+
end
|
|
83
174
|
end
|
|
84
175
|
end
|
data/lib/magick/feature.rb
CHANGED
|
@@ -85,7 +85,7 @@ module Magick
|
|
|
85
85
|
perf_metrics.record(name, 'enabled?', duration, success: false)
|
|
86
86
|
end
|
|
87
87
|
# Return false on any error (fail-safe)
|
|
88
|
-
warn "Magick: Error checking feature '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
88
|
+
warn "Magick: Error checking feature '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
89
89
|
false
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -152,7 +152,7 @@ module Magick
|
|
|
152
152
|
end
|
|
153
153
|
rescue StandardError => e
|
|
154
154
|
# Return false on any error (fail-safe)
|
|
155
|
-
warn "Magick: Error in check_enabled for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
155
|
+
warn "Magick: Error in check_enabled for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
156
156
|
false
|
|
157
157
|
end
|
|
158
158
|
|
|
@@ -223,7 +223,7 @@ module Magick
|
|
|
223
223
|
end
|
|
224
224
|
rescue StandardError => e
|
|
225
225
|
# Return default value on error (fail-safe)
|
|
226
|
-
warn "Magick: Error in get_value for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
226
|
+
warn "Magick: Error in get_value for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
227
227
|
default_value
|
|
228
228
|
end
|
|
229
229
|
|
|
@@ -310,7 +310,15 @@ module Magick
|
|
|
310
310
|
end
|
|
311
311
|
|
|
312
312
|
def exclude_ip_addresses(ip_addresses)
|
|
313
|
-
|
|
313
|
+
# excluded_ip_addresses is stored as a flat Array of strings; bypass the
|
|
314
|
+
# generic enable_targeting path whose "array type" branch stringifies the
|
|
315
|
+
# incoming array into a single element.
|
|
316
|
+
@targeting[:excluded_ip_addresses] ||= []
|
|
317
|
+
Array(ip_addresses).each do |ip|
|
|
318
|
+
str = ip.to_s
|
|
319
|
+
@targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
|
|
320
|
+
end
|
|
321
|
+
save_targeting
|
|
314
322
|
true
|
|
315
323
|
end
|
|
316
324
|
|
|
@@ -370,7 +378,15 @@ module Magick
|
|
|
370
378
|
end
|
|
371
379
|
|
|
372
380
|
def enable_for_ip_addresses(ip_addresses)
|
|
373
|
-
|
|
381
|
+
# ip_address is stored as a flat Array of strings; bypass the generic
|
|
382
|
+
# enable_targeting path whose "array type" branch stringifies the
|
|
383
|
+
# incoming array into a single '["x.y.z"]' entry.
|
|
384
|
+
@targeting[:ip_address] ||= []
|
|
385
|
+
Array(ip_addresses).each do |ip|
|
|
386
|
+
str = ip.to_s
|
|
387
|
+
@targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
|
|
388
|
+
end
|
|
389
|
+
save_targeting
|
|
374
390
|
true
|
|
375
391
|
end
|
|
376
392
|
|
|
@@ -645,15 +661,31 @@ module Magick
|
|
|
645
661
|
{
|
|
646
662
|
name: name,
|
|
647
663
|
display_name: display_name,
|
|
664
|
+
group: group,
|
|
648
665
|
type: type,
|
|
649
666
|
status: status,
|
|
650
667
|
value: stored_value,
|
|
651
668
|
default_value: default_value,
|
|
652
669
|
description: description,
|
|
653
|
-
targeting: targeting
|
|
670
|
+
targeting: targeting,
|
|
671
|
+
dependencies: (@dependencies || []).dup,
|
|
672
|
+
variants: variants_for_export
|
|
654
673
|
}
|
|
655
674
|
end
|
|
656
675
|
|
|
676
|
+
def variants_for_export
|
|
677
|
+
return [] unless defined?(Magick::FeatureVariant)
|
|
678
|
+
|
|
679
|
+
raw = @variants || []
|
|
680
|
+
raw.map do |v|
|
|
681
|
+
if v.is_a?(Magick::FeatureVariant)
|
|
682
|
+
{ name: v.name, weight: v.weight, value: v.value }
|
|
683
|
+
else
|
|
684
|
+
v
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
657
689
|
def save_targeting
|
|
658
690
|
# Save targeting to adapter (this updates memory synchronously, then Redis/AR)
|
|
659
691
|
# The set method already publishes cache invalidation to other processes via Pub/Sub
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
# Sanitize strings before they go into logs / warnings.
|
|
5
|
+
# Two concerns:
|
|
6
|
+
# 1) Newlines in a user-influenced string (feature name, exception
|
|
7
|
+
# message) let an attacker forge log entries ("log injection").
|
|
8
|
+
# 2) A long payload can flood a log pipeline.
|
|
9
|
+
# `LogSafe.sanitize` returns a single line at most 256 chars, control
|
|
10
|
+
# characters replaced with spaces.
|
|
11
|
+
module LogSafe
|
|
12
|
+
MAX_LEN = 256
|
|
13
|
+
CONTROL_CHARS = /[\r\n\t\e\u0000-\u001f\u007f]/.freeze
|
|
14
|
+
|
|
15
|
+
def self.sanitize(value, max: MAX_LEN)
|
|
16
|
+
str = value.to_s.dup
|
|
17
|
+
str.gsub!(CONTROL_CHARS, ' ')
|
|
18
|
+
str = str[0, max] if str.length > max
|
|
19
|
+
str
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Magick
|
|
4
4
|
class PerformanceMetrics
|
|
5
|
+
METRICS_RING_CAP = 1_000
|
|
6
|
+
|
|
5
7
|
class Metric
|
|
6
8
|
attr_reader :feature_name, :operation, :duration, :timestamp, :success
|
|
7
9
|
|
|
@@ -45,7 +47,26 @@ module Magick
|
|
|
45
47
|
@async_queue = Queue.new
|
|
46
48
|
@async_thread = nil
|
|
47
49
|
@async_enabled = true # Enable async by default for performance
|
|
50
|
+
@owner_pid = Process.pid
|
|
51
|
+
start_async_processor
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Restart the async processor after a fork. Child processes inherit a dead
|
|
55
|
+
# thread reference + a queue that was populated in the parent; both must
|
|
56
|
+
# be recreated. The inherited thread (if alive in the parent's address
|
|
57
|
+
# space at fork time) is killed so it cannot keep polling a detached queue.
|
|
58
|
+
def ensure_async_processor!
|
|
59
|
+
return if @owner_pid == Process.pid
|
|
60
|
+
|
|
61
|
+
stale_thread = @async_thread
|
|
62
|
+
stale_queue = @async_queue
|
|
63
|
+
@async_queue = Queue.new
|
|
64
|
+
@async_thread = nil
|
|
65
|
+
@owner_pid = Process.pid
|
|
48
66
|
start_async_processor
|
|
67
|
+
|
|
68
|
+
stale_queue&.close if stale_queue.respond_to?(:close)
|
|
69
|
+
stale_thread&.kill
|
|
49
70
|
end
|
|
50
71
|
|
|
51
72
|
# Public accessor for redis_enabled
|
|
@@ -74,8 +95,9 @@ module Magick
|
|
|
74
95
|
pending_count = nil
|
|
75
96
|
total_pending = nil
|
|
76
97
|
@mutex.synchronize do
|
|
77
|
-
#
|
|
78
|
-
|
|
98
|
+
# Pre-insert cap: construct + append only when there is room.
|
|
99
|
+
# Keeps @metrics bounded at METRICS_RING_CAP under any load.
|
|
100
|
+
if @metrics.length < METRICS_RING_CAP
|
|
79
101
|
metric = Metric.new(feature_name_str, operation_str, duration, success: success)
|
|
80
102
|
@metrics << metric
|
|
81
103
|
end
|
|
@@ -83,8 +105,6 @@ module Magick
|
|
|
83
105
|
@pending_updates[feature_name_str] += 1
|
|
84
106
|
pending_count = @pending_updates[feature_name_str]
|
|
85
107
|
total_pending = @pending_updates.values.sum
|
|
86
|
-
# Keep only last 1000 metrics (as a safety limit)
|
|
87
|
-
@metrics.shift if @metrics.length > 1000
|
|
88
108
|
end
|
|
89
109
|
|
|
90
110
|
# Rails 8+ event for usage tracking (cached check)
|
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -119,12 +119,29 @@ if defined?(Rails)
|
|
|
119
119
|
config.to_prepare do
|
|
120
120
|
RequestStore.store[:magick_features] ||= {} if defined?(RequestStore)
|
|
121
121
|
|
|
122
|
+
# Restart background threads after Puma fork. ensure_*! is a no-op
|
|
123
|
+
# when Process.pid matches the owner pid, so it is cheap to call.
|
|
124
|
+
registry = Magick.adapter_registry
|
|
125
|
+
registry.ensure_subscriber! if registry.respond_to?(:ensure_subscriber!)
|
|
126
|
+
Magick.performance_metrics&.ensure_async_processor!
|
|
127
|
+
|
|
122
128
|
# Final check: ensure Redis tracking is enabled (runs on every request in development)
|
|
123
129
|
# This is the absolute last chance to enable it
|
|
124
130
|
if Magick.performance_metrics && Magick.adapter_registry.is_a?(Adapters::Registry) && Magick.adapter_registry.redis_available? && !Magick.performance_metrics.redis_enabled
|
|
125
131
|
Magick.performance_metrics.enable_redis_tracking(enable: true)
|
|
126
132
|
end
|
|
127
133
|
end
|
|
134
|
+
|
|
135
|
+
# Terminate the Pub/Sub subscriber + async metrics thread on process exit.
|
|
136
|
+
# Without this, Ruby waits on the blocking `Redis#subscribe` call inside
|
|
137
|
+
# the subscriber thread and Puma/Rails shutdown stalls.
|
|
138
|
+
initializer 'magick.shutdown_hook' do
|
|
139
|
+
at_exit do
|
|
140
|
+
Magick.shutdown!
|
|
141
|
+
rescue StandardError
|
|
142
|
+
# Best-effort: never raise from an at_exit handler.
|
|
143
|
+
end
|
|
144
|
+
end
|
|
128
145
|
end
|
|
129
146
|
end
|
|
130
147
|
|
data/lib/magick/version.rb
CHANGED
data/lib/magick/versioning.rb
CHANGED
|
@@ -30,19 +30,21 @@ module Magick
|
|
|
30
30
|
|
|
31
31
|
def save_version(feature_name, version: nil, created_by: nil)
|
|
32
32
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@versions[
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
feature_name_str = feature_name.to_s
|
|
34
|
+
|
|
35
|
+
# Compute version + append under the same mutex so two concurrent
|
|
36
|
+
# save_version calls on the same feature can't both assign version N.
|
|
37
|
+
version_data = @mutex.synchronize do
|
|
38
|
+
list = (@versions[feature_name_str] ||= [])
|
|
39
|
+
resolved_version = version || (list.empty? ? 1 : list.last.version + 1)
|
|
40
|
+
entry = Version.new(resolved_version, feature.to_h, created_by: created_by)
|
|
41
|
+
list << entry
|
|
42
|
+
@adapter_registry.set(feature_name, "version_#{resolved_version}", entry.to_h)
|
|
43
|
+
entry
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
# Rails 8+ event
|
|
44
46
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
45
|
-
Magick::Rails::Events.version_saved(feature_name, version: version, created_by: created_by)
|
|
47
|
+
Magick::Rails::Events.version_saved(feature_name, version: version_data.version, created_by: created_by)
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
version_data
|
|
@@ -83,14 +85,16 @@ module Magick
|
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
def get_versions(feature_name)
|
|
86
|
-
@versions[feature_name.to_s] || []
|
|
88
|
+
@mutex.synchronize { (@versions[feature_name.to_s] || []).dup }
|
|
87
89
|
end
|
|
88
90
|
|
|
89
91
|
private
|
|
90
92
|
|
|
91
93
|
def next_version(feature_name)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
list = @versions[feature_name.to_s] || []
|
|
96
|
+
list.empty? ? 1 : list.last.version + 1
|
|
97
|
+
end
|
|
94
98
|
end
|
|
95
99
|
end
|
|
96
100
|
end
|
data/lib/magick.rb
CHANGED
|
@@ -18,8 +18,13 @@ require_relative 'magick/targeting/group'
|
|
|
18
18
|
require_relative 'magick/targeting/role'
|
|
19
19
|
require_relative 'magick/targeting/percentage'
|
|
20
20
|
require_relative 'magick/targeting/request_percentage'
|
|
21
|
+
require_relative 'magick/targeting/date_range'
|
|
22
|
+
require_relative 'magick/targeting/ip_address'
|
|
23
|
+
require_relative 'magick/targeting/custom_attribute'
|
|
24
|
+
require_relative 'magick/targeting/complex'
|
|
21
25
|
require_relative 'magick/errors'
|
|
22
26
|
|
|
27
|
+
require_relative 'magick/log_safe'
|
|
23
28
|
require_relative 'magick/audit_log'
|
|
24
29
|
require_relative 'magick/performance_metrics'
|
|
25
30
|
require_relative 'magick/export_import'
|
|
@@ -114,13 +119,22 @@ module Magick
|
|
|
114
119
|
features[feature_name.to_s] || Feature.new(feature_name, adapter_registry || default_adapter_registry)
|
|
115
120
|
end
|
|
116
121
|
|
|
122
|
+
# Mutex that guards writes to @features. Reads are lock-free by design:
|
|
123
|
+
# register_feature swaps the @features reference to a NEW hash via
|
|
124
|
+
# copy-on-write, so a concurrent iterator keeps its own snapshot and
|
|
125
|
+
# never sees "can't add a new key into hash during iteration".
|
|
126
|
+
FEATURES_MUTEX = Mutex.new
|
|
127
|
+
private_constant :FEATURES_MUTEX
|
|
128
|
+
|
|
117
129
|
def features
|
|
118
130
|
@features ||= {}
|
|
119
131
|
end
|
|
120
132
|
|
|
121
133
|
def register_feature(name, **options)
|
|
122
134
|
feature = Feature.new(name, adapter_registry || default_adapter_registry, **options)
|
|
123
|
-
|
|
135
|
+
FEATURES_MUTEX.synchronize do
|
|
136
|
+
@features = (@features || {}).merge(name.to_s => feature)
|
|
137
|
+
end
|
|
124
138
|
feature
|
|
125
139
|
end
|
|
126
140
|
|
|
@@ -193,7 +207,9 @@ module Magick
|
|
|
193
207
|
|
|
194
208
|
def import(data, format: :json)
|
|
195
209
|
imported = ExportImport.import(data, adapter_registry || default_adapter_registry)
|
|
196
|
-
|
|
210
|
+
FEATURES_MUTEX.synchronize do
|
|
211
|
+
@features = (@features || {}).merge(imported)
|
|
212
|
+
end
|
|
197
213
|
imported
|
|
198
214
|
end
|
|
199
215
|
|
|
@@ -259,12 +275,24 @@ module Magick
|
|
|
259
275
|
end
|
|
260
276
|
|
|
261
277
|
def reset!
|
|
278
|
+
safely_shutdown(@adapter_registry) { |r| r.shutdown }
|
|
279
|
+
safely_shutdown(@default_adapter_registry) { |r| r.shutdown }
|
|
262
280
|
@features = {}
|
|
263
281
|
@adapter_registry = nil
|
|
264
282
|
@default_adapter = nil
|
|
283
|
+
@default_adapter_registry = nil
|
|
265
284
|
@performance_metrics&.clear!
|
|
266
285
|
end
|
|
267
286
|
|
|
287
|
+
# Gracefully terminate background threads (Redis Pub/Sub subscriber,
|
|
288
|
+
# async metrics processor) so the host process can exit promptly.
|
|
289
|
+
# Intended for use in Rails shutdown hooks, `at_exit`, or tests.
|
|
290
|
+
def shutdown!(timeout: 5)
|
|
291
|
+
safely_shutdown(@adapter_registry) { |r| r.shutdown(timeout: timeout) }
|
|
292
|
+
safely_shutdown(@performance_metrics, &:stop_async_processor)
|
|
293
|
+
true
|
|
294
|
+
end
|
|
295
|
+
|
|
268
296
|
# Get default adapter registry (public method for use by other classes)
|
|
269
297
|
def default_adapter_registry
|
|
270
298
|
@default_adapter_registry ||= begin
|
|
@@ -279,5 +307,15 @@ module Magick
|
|
|
279
307
|
end
|
|
280
308
|
|
|
281
309
|
private
|
|
310
|
+
|
|
311
|
+
# Run a cleanup action on a collaborator, swallowing errors so that
|
|
312
|
+
# shutdown hooks (at_exit, Rails) never raise.
|
|
313
|
+
def safely_shutdown(collaborator)
|
|
314
|
+
return unless collaborator
|
|
315
|
+
|
|
316
|
+
yield(collaborator)
|
|
317
|
+
rescue StandardError
|
|
318
|
+
# Best-effort: termination paths must not raise.
|
|
319
|
+
end
|
|
282
320
|
end
|
|
283
321
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: magick-feature-flags
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Lobanov
|
|
@@ -130,6 +130,7 @@ files:
|
|
|
130
130
|
- lib/magick/feature.rb
|
|
131
131
|
- lib/magick/feature_dependency.rb
|
|
132
132
|
- lib/magick/feature_variant.rb
|
|
133
|
+
- lib/magick/log_safe.rb
|
|
133
134
|
- lib/magick/performance_metrics.rb
|
|
134
135
|
- lib/magick/rails.rb
|
|
135
136
|
- lib/magick/rails/event_subscriber.rb
|