magick-feature-flags 1.3.2 → 1.4.2
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 +39 -14
- 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 +59 -11
- 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 +52 -54
- data/lib/magick/log_safe.rb +22 -0
- data/lib/magick/performance_metrics.rb +24 -4
- data/lib/magick/rails/railtie.rb +6 -0
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +17 -13
- data/lib/magick.rb +21 -3
- metadata +2 -2
- data/lib/magick/feature_dependency.rb +0 -28
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce69954c64fae4d8ec9d10d177621ef508c1f0d8db98b9012849250d90cc974f
|
|
4
|
+
data.tar.gz: 41024341850d984dc1b16c8e85913160a94eb92b96f5eaf07477d9ff93a46de4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a16aa4722424020d4d4ab0d45c1dc5bfec6452ae6f24177d713e6f98790d33b4851bb340e39603aecf52c06bf2d669d0876c0b3835bc9ebf4f63cd92e7776ccc
|
|
7
|
+
data.tar.gz: 3a49561daaf30a060abefd7c11413dd5e87e27771b59e089d287cb2788920f5f9c334c4922e064d03c18ce692b6f37a6bfeaf419f09ba12891313c3be1eb1f8a
|
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'
|
|
@@ -80,11 +87,8 @@ module Magick
|
|
|
80
87
|
end
|
|
81
88
|
|
|
82
89
|
def enable
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
else
|
|
86
|
-
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Cannot enable feature — its dependencies must be enabled first'
|
|
87
|
-
end
|
|
90
|
+
@feature.enable
|
|
91
|
+
redirect_to magick_admin_ui.features_path, notice: 'Feature enabled'
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
def disable
|
|
@@ -120,6 +124,10 @@ module Magick
|
|
|
120
124
|
def update_targeting
|
|
121
125
|
# Handle targeting updates from form
|
|
122
126
|
targeting_params = params[:targeting] || {}
|
|
127
|
+
unless hash_like?(targeting_params)
|
|
128
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid targeting payload.'
|
|
129
|
+
return
|
|
130
|
+
end
|
|
123
131
|
|
|
124
132
|
# Ensure we're using the registered feature instance
|
|
125
133
|
feature_name = @feature.name.to_s
|
|
@@ -266,13 +274,18 @@ module Magick
|
|
|
266
274
|
|
|
267
275
|
redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Targeting updated successfully'
|
|
268
276
|
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:
|
|
277
|
+
Rails.logger.error "Magick: Error updating targeting for #{@feature.name}: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
|
|
278
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update targeting — see server logs for details.'
|
|
271
279
|
end
|
|
272
280
|
|
|
273
281
|
def update_variants
|
|
274
282
|
variants_data = []
|
|
275
283
|
|
|
284
|
+
if params[:variants].present? && !hash_like?(params[:variants])
|
|
285
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid variants payload.'
|
|
286
|
+
return
|
|
287
|
+
end
|
|
288
|
+
|
|
276
289
|
if params[:variants].present?
|
|
277
290
|
params[:variants].each do |_index, variant_params|
|
|
278
291
|
next if variant_params[:name].blank?
|
|
@@ -295,8 +308,8 @@ module Magick
|
|
|
295
308
|
|
|
296
309
|
redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Variants updated successfully'
|
|
297
310
|
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:
|
|
311
|
+
Rails.logger.error "Magick: Error updating variants for #{@feature.name}: #{e.class}: #{e.message}" if defined?(Rails)
|
|
312
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update variants — see server logs for details.'
|
|
300
313
|
end
|
|
301
314
|
|
|
302
315
|
private
|
|
@@ -313,13 +326,25 @@ module Magick
|
|
|
313
326
|
end
|
|
314
327
|
end
|
|
315
328
|
|
|
329
|
+
# A hash-like payload is either a raw Hash or an
|
|
330
|
+
# ActionController::Parameters (both respond to :each with key/value).
|
|
331
|
+
# Rejecting anything else lets us give a 400-style redirect instead of
|
|
332
|
+
# a 500 with a NoMethodError stack trace.
|
|
333
|
+
def hash_like?(obj)
|
|
334
|
+
obj.is_a?(Hash) || (defined?(ActionController::Parameters) && obj.is_a?(ActionController::Parameters))
|
|
335
|
+
end
|
|
336
|
+
|
|
316
337
|
def set_feature
|
|
317
338
|
feature_name = params[:id].to_s
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
#
|
|
322
|
-
@feature = Magick.features[feature_name]
|
|
339
|
+
# Do NOT fall back to Magick[feature_name] — that would lazily create
|
|
340
|
+
# and persist a brand-new feature from user-controlled input, letting
|
|
341
|
+
# an attacker (or even a stray crawler) pollute Redis/AR with arbitrary
|
|
342
|
+
# keys. Look up only registered features; 404 otherwise.
|
|
343
|
+
@feature = Magick.features[feature_name]
|
|
344
|
+
return if @feature
|
|
345
|
+
|
|
346
|
+
redirect_to magick_admin_ui.features_path, alert: 'Feature not found'
|
|
347
|
+
nil
|
|
323
348
|
end
|
|
324
349
|
end
|
|
325
350
|
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
|
|
@@ -23,11 +29,31 @@ module Magick
|
|
|
23
29
|
@reload_mutex = Mutex.new
|
|
24
30
|
@stopping = false
|
|
25
31
|
@shutdown_mutex = Mutex.new
|
|
32
|
+
@owner_pid = Process.pid
|
|
26
33
|
# Only start Pub/Sub subscriber if Redis is available
|
|
27
34
|
# In memory-only mode, each process has isolated cache (no cross-process invalidation)
|
|
28
35
|
start_cache_invalidation_subscriber if redis_adapter
|
|
29
36
|
end
|
|
30
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
|
+
|
|
31
57
|
# Gracefully terminate the Pub/Sub subscriber thread and its Redis connection.
|
|
32
58
|
# Without this, Ruby/Puma shutdown waits on the blocking `subscribe` call.
|
|
33
59
|
def shutdown(timeout: 5)
|
|
@@ -99,11 +125,7 @@ module Magick
|
|
|
99
125
|
end
|
|
100
126
|
|
|
101
127
|
if @async && defined?(Thread)
|
|
102
|
-
|
|
103
|
-
update_redis.call
|
|
104
|
-
# Publish AFTER Redis write so other processes read fresh data
|
|
105
|
-
publish_cache_invalidation(feature_name)
|
|
106
|
-
end
|
|
128
|
+
spawn_async_write(feature_name, update_redis)
|
|
107
129
|
else
|
|
108
130
|
update_redis.call
|
|
109
131
|
publish_cache_invalidation(feature_name)
|
|
@@ -249,11 +271,7 @@ module Magick
|
|
|
249
271
|
end
|
|
250
272
|
|
|
251
273
|
if @async && defined?(Thread)
|
|
252
|
-
|
|
253
|
-
update_redis.call
|
|
254
|
-
# Publish AFTER Redis write so other processes read fresh data
|
|
255
|
-
publish_cache_invalidation(feature_name)
|
|
256
|
-
end
|
|
274
|
+
spawn_async_write(feature_name, update_redis)
|
|
257
275
|
else
|
|
258
276
|
update_redis.call
|
|
259
277
|
publish_cache_invalidation(feature_name)
|
|
@@ -337,11 +355,31 @@ module Magick
|
|
|
337
355
|
thread.join(1) # give it a moment to actually unwind
|
|
338
356
|
end
|
|
339
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
|
+
|
|
340
374
|
# Record that this process just wrote a feature, so the subscriber
|
|
341
375
|
# ignores its own Pub/Sub messages and doesn't revert the correct in-memory state.
|
|
342
376
|
def record_local_write(feature_name)
|
|
343
377
|
@reload_mutex.synchronize do
|
|
344
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
|
|
345
383
|
end
|
|
346
384
|
end
|
|
347
385
|
|
|
@@ -406,6 +444,16 @@ module Magick
|
|
|
406
444
|
on.message do |_channel, feature_name|
|
|
407
445
|
feature_name_str = feature_name.to_s
|
|
408
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
|
+
|
|
409
457
|
# Skip self-invalidation: if this process just wrote this feature,
|
|
410
458
|
# memory already has the correct value. Reloading from Redis would
|
|
411
459
|
# revert it to stale data (especially with async writes).
|
|
@@ -457,7 +505,7 @@ module Magick
|
|
|
457
505
|
end
|
|
458
506
|
|
|
459
507
|
if defined?(Rails) && Rails.env.development?
|
|
460
|
-
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)}"
|
|
461
509
|
end
|
|
462
510
|
end
|
|
463
511
|
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
|
|
|
@@ -107,6 +107,11 @@ module Magick
|
|
|
107
107
|
return false if status == :inactive
|
|
108
108
|
return false if status == :deprecated && !context[:allow_deprecated]
|
|
109
109
|
|
|
110
|
+
# Dependency check: a feature with unmet prerequisites evaluates as disabled,
|
|
111
|
+
# regardless of its own configured state. Evaluation-only — prerequisite state
|
|
112
|
+
# is never written into this feature.
|
|
113
|
+
return false unless dependencies_satisfied?(context)
|
|
114
|
+
|
|
110
115
|
# Fast path: skip targeting checks if targeting is empty (most common case)
|
|
111
116
|
unless @_targeting_empty
|
|
112
117
|
# Check exclusions FIRST — exclusions always take priority over inclusions
|
|
@@ -152,7 +157,7 @@ module Magick
|
|
|
152
157
|
end
|
|
153
158
|
rescue StandardError => e
|
|
154
159
|
# Return false on any error (fail-safe)
|
|
155
|
-
warn "Magick: Error in check_enabled for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
160
|
+
warn "Magick: Error in check_enabled for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
156
161
|
false
|
|
157
162
|
end
|
|
158
163
|
|
|
@@ -223,7 +228,7 @@ module Magick
|
|
|
223
228
|
end
|
|
224
229
|
rescue StandardError => e
|
|
225
230
|
# Return default value on error (fail-safe)
|
|
226
|
-
warn "Magick: Error in get_value for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
231
|
+
warn "Magick: Error in get_value for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
227
232
|
default_value
|
|
228
233
|
end
|
|
229
234
|
|
|
@@ -310,7 +315,15 @@ module Magick
|
|
|
310
315
|
end
|
|
311
316
|
|
|
312
317
|
def exclude_ip_addresses(ip_addresses)
|
|
313
|
-
|
|
318
|
+
# excluded_ip_addresses is stored as a flat Array of strings; bypass the
|
|
319
|
+
# generic enable_targeting path whose "array type" branch stringifies the
|
|
320
|
+
# incoming array into a single element.
|
|
321
|
+
@targeting[:excluded_ip_addresses] ||= []
|
|
322
|
+
Array(ip_addresses).each do |ip|
|
|
323
|
+
str = ip.to_s
|
|
324
|
+
@targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
|
|
325
|
+
end
|
|
326
|
+
save_targeting
|
|
314
327
|
true
|
|
315
328
|
end
|
|
316
329
|
|
|
@@ -370,7 +383,15 @@ module Magick
|
|
|
370
383
|
end
|
|
371
384
|
|
|
372
385
|
def enable_for_ip_addresses(ip_addresses)
|
|
373
|
-
|
|
386
|
+
# ip_address is stored as a flat Array of strings; bypass the generic
|
|
387
|
+
# enable_targeting path whose "array type" branch stringifies the
|
|
388
|
+
# incoming array into a single '["x.y.z"]' entry.
|
|
389
|
+
@targeting[:ip_address] ||= []
|
|
390
|
+
Array(ip_addresses).each do |ip|
|
|
391
|
+
str = ip.to_s
|
|
392
|
+
@targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
|
|
393
|
+
end
|
|
394
|
+
save_targeting
|
|
374
395
|
true
|
|
375
396
|
end
|
|
376
397
|
|
|
@@ -516,18 +537,6 @@ module Magick
|
|
|
516
537
|
end
|
|
517
538
|
|
|
518
539
|
def enable(user_id: nil)
|
|
519
|
-
# Check that all of this feature's own dependencies are enabled
|
|
520
|
-
# e.g. if checkout depends on payments, checkout can't be enabled until payments is
|
|
521
|
-
deps = @dependencies || []
|
|
522
|
-
unless deps.empty?
|
|
523
|
-
disabled_deps = deps.select do |dep_name|
|
|
524
|
-
dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
|
|
525
|
-
dep_feature && !dep_feature.enabled?
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
return false unless disabled_deps.empty?
|
|
529
|
-
end
|
|
530
|
-
|
|
531
540
|
# Clear all targeting to enable globally
|
|
532
541
|
@targeting = {}
|
|
533
542
|
save_targeting
|
|
@@ -573,9 +582,6 @@ module Magick
|
|
|
573
582
|
registered.instance_variable_set(:@targeting, {})
|
|
574
583
|
end
|
|
575
584
|
|
|
576
|
-
# Cascade disable: disable all features that depend on this one
|
|
577
|
-
disable_dependent_features(user_id: user_id)
|
|
578
|
-
|
|
579
585
|
# Rails 8+ event
|
|
580
586
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
581
587
|
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
@@ -645,15 +651,31 @@ module Magick
|
|
|
645
651
|
{
|
|
646
652
|
name: name,
|
|
647
653
|
display_name: display_name,
|
|
654
|
+
group: group,
|
|
648
655
|
type: type,
|
|
649
656
|
status: status,
|
|
650
657
|
value: stored_value,
|
|
651
658
|
default_value: default_value,
|
|
652
659
|
description: description,
|
|
653
|
-
targeting: targeting
|
|
660
|
+
targeting: targeting,
|
|
661
|
+
dependencies: (@dependencies || []).dup,
|
|
662
|
+
variants: variants_for_export
|
|
654
663
|
}
|
|
655
664
|
end
|
|
656
665
|
|
|
666
|
+
def variants_for_export
|
|
667
|
+
return [] unless defined?(Magick::FeatureVariant)
|
|
668
|
+
|
|
669
|
+
raw = @variants || []
|
|
670
|
+
raw.map do |v|
|
|
671
|
+
if v.is_a?(Magick::FeatureVariant)
|
|
672
|
+
{ name: v.name, weight: v.weight, value: v.value }
|
|
673
|
+
else
|
|
674
|
+
v
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
657
679
|
def save_targeting
|
|
658
680
|
# Save targeting to adapter (this updates memory synchronously, then Redis/AR)
|
|
659
681
|
# The set method already publishes cache invalidation to other processes via Pub/Sub
|
|
@@ -983,42 +1005,18 @@ module Magick
|
|
|
983
1005
|
context
|
|
984
1006
|
end
|
|
985
1007
|
|
|
986
|
-
def
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
dependents = find_dependent_features
|
|
990
|
-
return if dependents.empty?
|
|
991
|
-
|
|
992
|
-
dependents.each do |dep_name|
|
|
993
|
-
dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
|
|
994
|
-
next unless dep_feature
|
|
995
|
-
|
|
996
|
-
dep_feature.instance_variable_set(:@targeting, {})
|
|
997
|
-
dep_feature.save_targeting
|
|
1008
|
+
def dependencies_satisfied?(context)
|
|
1009
|
+
deps = @dependencies
|
|
1010
|
+
return true if deps.nil? || deps.empty?
|
|
998
1011
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
when :number
|
|
1005
|
-
dep_feature.set_value(0, user_id: user_id)
|
|
1006
|
-
end
|
|
1007
|
-
|
|
1008
|
-
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
1009
|
-
Magick::Rails::Events.feature_disabled_globally(dep_name, user_id: user_id)
|
|
1010
|
-
end
|
|
1011
|
-
end
|
|
1012
|
-
end
|
|
1012
|
+
deps.all? do |dep_name|
|
|
1013
|
+
dep_feature = Magick.features[dep_name.to_s] || Magick.features[dep_name.to_sym]
|
|
1014
|
+
# Unknown dependency is treated as satisfied — matches prior behavior
|
|
1015
|
+
# where missing features were skipped in cascade logic.
|
|
1016
|
+
next true unless dep_feature
|
|
1013
1017
|
|
|
1014
|
-
|
|
1015
|
-
# Find all features that have this feature in their dependencies
|
|
1016
|
-
dependent_features = []
|
|
1017
|
-
Magick.features.each do |_name, feature|
|
|
1018
|
-
feature_deps = feature.instance_variable_get(:@dependencies) || []
|
|
1019
|
-
dependent_features << feature.name if feature_deps.include?(name.to_s) || feature_deps.include?(name.to_sym)
|
|
1018
|
+
dep_feature.enabled?(context)
|
|
1020
1019
|
end
|
|
1021
|
-
dependent_features
|
|
1022
1020
|
end
|
|
1023
1021
|
|
|
1024
1022
|
def excluded?(context)
|
|
@@ -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,6 +119,12 @@ 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
|
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,15 +18,19 @@ 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'
|
|
26
31
|
require_relative 'magick/versioning'
|
|
27
32
|
require_relative 'magick/circuit_breaker'
|
|
28
33
|
require_relative 'magick/testing_helpers'
|
|
29
|
-
require_relative 'magick/feature_dependency'
|
|
30
34
|
require_relative 'magick/documentation'
|
|
31
35
|
# AdminUI is loaded conditionally via configuration
|
|
32
36
|
# It is not loaded by default - must be enabled in Magick.configure
|
|
@@ -114,13 +118,22 @@ module Magick
|
|
|
114
118
|
features[feature_name.to_s] || Feature.new(feature_name, adapter_registry || default_adapter_registry)
|
|
115
119
|
end
|
|
116
120
|
|
|
121
|
+
# Mutex that guards writes to @features. Reads are lock-free by design:
|
|
122
|
+
# register_feature swaps the @features reference to a NEW hash via
|
|
123
|
+
# copy-on-write, so a concurrent iterator keeps its own snapshot and
|
|
124
|
+
# never sees "can't add a new key into hash during iteration".
|
|
125
|
+
FEATURES_MUTEX = Mutex.new
|
|
126
|
+
private_constant :FEATURES_MUTEX
|
|
127
|
+
|
|
117
128
|
def features
|
|
118
129
|
@features ||= {}
|
|
119
130
|
end
|
|
120
131
|
|
|
121
132
|
def register_feature(name, **options)
|
|
122
133
|
feature = Feature.new(name, adapter_registry || default_adapter_registry, **options)
|
|
123
|
-
|
|
134
|
+
FEATURES_MUTEX.synchronize do
|
|
135
|
+
@features = (@features || {}).merge(name.to_s => feature)
|
|
136
|
+
end
|
|
124
137
|
feature
|
|
125
138
|
end
|
|
126
139
|
|
|
@@ -193,7 +206,9 @@ module Magick
|
|
|
193
206
|
|
|
194
207
|
def import(data, format: :json)
|
|
195
208
|
imported = ExportImport.import(data, adapter_registry || default_adapter_registry)
|
|
196
|
-
|
|
209
|
+
FEATURES_MUTEX.synchronize do
|
|
210
|
+
@features = (@features || {}).merge(imported)
|
|
211
|
+
end
|
|
197
212
|
imported
|
|
198
213
|
end
|
|
199
214
|
|
|
@@ -259,9 +274,12 @@ module Magick
|
|
|
259
274
|
end
|
|
260
275
|
|
|
261
276
|
def reset!
|
|
277
|
+
safely_shutdown(@adapter_registry) { |r| r.shutdown }
|
|
278
|
+
safely_shutdown(@default_adapter_registry) { |r| r.shutdown }
|
|
262
279
|
@features = {}
|
|
263
280
|
@adapter_registry = nil
|
|
264
281
|
@default_adapter = nil
|
|
282
|
+
@default_adapter_registry = nil
|
|
265
283
|
@performance_metrics&.clear!
|
|
266
284
|
end
|
|
267
285
|
|
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Lobanov
|
|
@@ -128,8 +128,8 @@ files:
|
|
|
128
128
|
- lib/magick/errors.rb
|
|
129
129
|
- lib/magick/export_import.rb
|
|
130
130
|
- lib/magick/feature.rb
|
|
131
|
-
- lib/magick/feature_dependency.rb
|
|
132
131
|
- lib/magick/feature_variant.rb
|
|
132
|
+
- lib/magick/log_safe.rb
|
|
133
133
|
- lib/magick/performance_metrics.rb
|
|
134
134
|
- lib/magick/rails.rb
|
|
135
135
|
- lib/magick/rails/event_subscriber.rb
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Magick
|
|
4
|
-
class FeatureDependency
|
|
5
|
-
def self.check(feature_name, context = {})
|
|
6
|
-
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
7
|
-
dependencies = feature.instance_variable_get(:@dependencies) || []
|
|
8
|
-
|
|
9
|
-
dependencies.all? do |dep_name|
|
|
10
|
-
Magick.enabled?(dep_name, context)
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def self.add_dependency(feature_name, dependency_name)
|
|
15
|
-
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
16
|
-
dependencies = feature.instance_variable_get(:@dependencies) || []
|
|
17
|
-
dependencies << dependency_name.to_s unless dependencies.include?(dependency_name.to_s)
|
|
18
|
-
feature.instance_variable_set(:@dependencies, dependencies)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def self.remove_dependency(feature_name, dependency_name)
|
|
22
|
-
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
23
|
-
dependencies = feature.instance_variable_get(:@dependencies) || []
|
|
24
|
-
dependencies.delete(dependency_name.to_s)
|
|
25
|
-
feature.instance_variable_set(:@dependencies, dependencies)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|