magick-feature-flags 1.4.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +97 -3
- data/app/controllers/magick/adminui/features_controller.rb +73 -141
- data/config/magick.rb.example +5 -3
- data/lib/generators/magick/install/templates/magick.rb +5 -3
- data/lib/magick/adapters/registry.rb +8 -2
- data/lib/magick/admin_ui.rb +5 -1
- data/lib/magick/config.rb +10 -4
- data/lib/magick/errors.rb +4 -0
- data/lib/magick/export_import.rb +9 -46
- data/lib/magick/feature.rb +362 -167
- data/lib/magick/rails/railtie.rb +5 -2
- data/lib/magick/targeting_payload.rb +237 -0
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +205 -51
- data/lib/magick.rb +62 -0
- metadata +2 -1
data/lib/magick/feature.rb
CHANGED
|
@@ -9,7 +9,8 @@ module Magick
|
|
|
9
9
|
VALID_TYPES = %i[boolean string number].freeze
|
|
10
10
|
VALID_STATUSES = %i[active inactive deprecated].freeze
|
|
11
11
|
|
|
12
|
-
attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry
|
|
12
|
+
attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry,
|
|
13
|
+
:targeting
|
|
13
14
|
|
|
14
15
|
def initialize(name, adapter_registry, **options)
|
|
15
16
|
@name = name.to_s
|
|
@@ -233,187 +234,246 @@ module Magick
|
|
|
233
234
|
end
|
|
234
235
|
|
|
235
236
|
def enable_for_user(user_id)
|
|
236
|
-
|
|
237
|
+
record_change('enable_for_user', targeting_change(:user, added: user_id)) do
|
|
238
|
+
enable_targeting(:user, user_id)
|
|
239
|
+
end
|
|
237
240
|
true
|
|
238
241
|
end
|
|
239
242
|
|
|
240
243
|
def disable_for_user(user_id)
|
|
241
|
-
|
|
244
|
+
record_change('disable_for_user', targeting_change(:user, removed: user_id)) do
|
|
245
|
+
disable_targeting(:user, user_id)
|
|
246
|
+
end
|
|
242
247
|
true
|
|
243
248
|
end
|
|
244
249
|
|
|
245
250
|
def enable_for_group(group_name)
|
|
246
|
-
|
|
251
|
+
record_change('enable_for_group', targeting_change(:group, added: group_name)) do
|
|
252
|
+
enable_targeting(:group, group_name)
|
|
253
|
+
end
|
|
247
254
|
true
|
|
248
255
|
end
|
|
249
256
|
|
|
250
257
|
def disable_for_group(group_name)
|
|
251
|
-
|
|
258
|
+
record_change('disable_for_group', targeting_change(:group, removed: group_name)) do
|
|
259
|
+
disable_targeting(:group, group_name)
|
|
260
|
+
end
|
|
252
261
|
true
|
|
253
262
|
end
|
|
254
263
|
|
|
255
264
|
def enable_for_role(role_name)
|
|
256
|
-
|
|
265
|
+
record_change('enable_for_role', targeting_change(:role, added: role_name)) do
|
|
266
|
+
enable_targeting(:role, role_name)
|
|
267
|
+
end
|
|
257
268
|
true
|
|
258
269
|
end
|
|
259
270
|
|
|
260
271
|
def disable_for_role(role_name)
|
|
261
|
-
|
|
272
|
+
record_change('disable_for_role', targeting_change(:role, removed: role_name)) do
|
|
273
|
+
disable_targeting(:role, role_name)
|
|
274
|
+
end
|
|
262
275
|
true
|
|
263
276
|
end
|
|
264
277
|
|
|
265
278
|
def enable_for_tag(tag_name)
|
|
266
|
-
|
|
279
|
+
record_change('enable_for_tag', targeting_change(:tag, added: tag_name)) do
|
|
280
|
+
enable_targeting(:tag, tag_name)
|
|
281
|
+
end
|
|
267
282
|
true
|
|
268
283
|
end
|
|
269
284
|
|
|
270
285
|
def disable_for_tag(tag_name)
|
|
271
|
-
|
|
286
|
+
record_change('disable_for_tag', targeting_change(:tag, removed: tag_name)) do
|
|
287
|
+
disable_targeting(:tag, tag_name)
|
|
288
|
+
end
|
|
272
289
|
true
|
|
273
290
|
end
|
|
274
291
|
|
|
275
292
|
# --- Exclusion methods ---
|
|
276
293
|
|
|
277
294
|
def exclude_user(user_id)
|
|
278
|
-
|
|
295
|
+
record_change('exclude_user', targeting_change(:excluded_users, added: user_id)) do
|
|
296
|
+
enable_targeting(:excluded_users, user_id)
|
|
297
|
+
end
|
|
279
298
|
true
|
|
280
299
|
end
|
|
281
300
|
|
|
282
301
|
def remove_user_exclusion(user_id)
|
|
283
|
-
|
|
302
|
+
record_change('remove_user_exclusion', targeting_change(:excluded_users, removed: user_id)) do
|
|
303
|
+
disable_targeting(:excluded_users, user_id)
|
|
304
|
+
end
|
|
284
305
|
true
|
|
285
306
|
end
|
|
286
307
|
|
|
287
308
|
def exclude_tag(tag_name)
|
|
288
|
-
|
|
309
|
+
record_change('exclude_tag', targeting_change(:excluded_tags, added: tag_name)) do
|
|
310
|
+
enable_targeting(:excluded_tags, tag_name)
|
|
311
|
+
end
|
|
289
312
|
true
|
|
290
313
|
end
|
|
291
314
|
|
|
292
315
|
def remove_tag_exclusion(tag_name)
|
|
293
|
-
|
|
316
|
+
record_change('remove_tag_exclusion', targeting_change(:excluded_tags, removed: tag_name)) do
|
|
317
|
+
disable_targeting(:excluded_tags, tag_name)
|
|
318
|
+
end
|
|
294
319
|
true
|
|
295
320
|
end
|
|
296
321
|
|
|
297
322
|
def exclude_group(group_name)
|
|
298
|
-
|
|
323
|
+
record_change('exclude_group', targeting_change(:excluded_groups, added: group_name)) do
|
|
324
|
+
enable_targeting(:excluded_groups, group_name)
|
|
325
|
+
end
|
|
299
326
|
true
|
|
300
327
|
end
|
|
301
328
|
|
|
302
329
|
def remove_group_exclusion(group_name)
|
|
303
|
-
|
|
330
|
+
record_change('remove_group_exclusion', targeting_change(:excluded_groups, removed: group_name)) do
|
|
331
|
+
disable_targeting(:excluded_groups, group_name)
|
|
332
|
+
end
|
|
304
333
|
true
|
|
305
334
|
end
|
|
306
335
|
|
|
307
336
|
def exclude_role(role_name)
|
|
308
|
-
|
|
337
|
+
record_change('exclude_role', targeting_change(:excluded_roles, added: role_name)) do
|
|
338
|
+
enable_targeting(:excluded_roles, role_name)
|
|
339
|
+
end
|
|
309
340
|
true
|
|
310
341
|
end
|
|
311
342
|
|
|
312
343
|
def remove_role_exclusion(role_name)
|
|
313
|
-
|
|
344
|
+
record_change('remove_role_exclusion', targeting_change(:excluded_roles, removed: role_name)) do
|
|
345
|
+
disable_targeting(:excluded_roles, role_name)
|
|
346
|
+
end
|
|
314
347
|
true
|
|
315
348
|
end
|
|
316
349
|
|
|
317
350
|
def exclude_ip_addresses(ip_addresses)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
351
|
+
ips = Array(ip_addresses).map(&:to_s)
|
|
352
|
+
record_change('exclude_ip_addresses', targeting_change(:excluded_ip_addresses, added: ips)) do
|
|
353
|
+
# excluded_ip_addresses is stored as a flat Array of strings; bypass the
|
|
354
|
+
# generic enable_targeting path whose "array type" branch stringifies the
|
|
355
|
+
# incoming array into a single element.
|
|
356
|
+
@targeting[:excluded_ip_addresses] ||= []
|
|
357
|
+
ips.each do |str|
|
|
358
|
+
@targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
|
|
359
|
+
end
|
|
360
|
+
save_targeting
|
|
325
361
|
end
|
|
326
|
-
save_targeting
|
|
327
362
|
true
|
|
328
363
|
end
|
|
329
364
|
|
|
330
365
|
def remove_ip_exclusion
|
|
331
|
-
|
|
366
|
+
record_change('remove_ip_exclusion', targeting_change(:excluded_ip_addresses)) do
|
|
367
|
+
disable_targeting(:excluded_ip_addresses)
|
|
368
|
+
end
|
|
332
369
|
true
|
|
333
370
|
end
|
|
334
371
|
|
|
335
372
|
def enable_percentage_of_users(percentage)
|
|
336
|
-
|
|
337
|
-
|
|
373
|
+
record_change('enable_percentage_of_users', targeting_change(:percentage_users, added: percentage.to_f)) do
|
|
374
|
+
@targeting[:percentage_users] = percentage.to_f
|
|
375
|
+
save_targeting
|
|
338
376
|
|
|
339
|
-
|
|
340
|
-
|
|
377
|
+
# Update registered feature instance if it exists
|
|
378
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
341
379
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
380
|
+
# Rails 8+ event
|
|
381
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
382
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
|
|
383
|
+
end
|
|
345
384
|
end
|
|
346
385
|
|
|
347
386
|
true
|
|
348
387
|
end
|
|
349
388
|
|
|
350
389
|
def disable_percentage_of_users
|
|
351
|
-
|
|
390
|
+
record_change('disable_percentage_of_users', targeting_change(:percentage_users)) do
|
|
391
|
+
disable_targeting(:percentage_users)
|
|
392
|
+
end
|
|
352
393
|
true
|
|
353
394
|
end
|
|
354
395
|
|
|
355
396
|
def enable_percentage_of_requests(percentage)
|
|
356
|
-
|
|
357
|
-
|
|
397
|
+
record_change('enable_percentage_of_requests', targeting_change(:percentage_requests, added: percentage.to_f)) do
|
|
398
|
+
@targeting[:percentage_requests] = percentage.to_f
|
|
399
|
+
save_targeting
|
|
358
400
|
|
|
359
|
-
|
|
360
|
-
|
|
401
|
+
# Update registered feature instance if it exists
|
|
402
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
361
403
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
404
|
+
# Rails 8+ event
|
|
405
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
406
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
|
|
407
|
+
end
|
|
365
408
|
end
|
|
366
409
|
|
|
367
410
|
true
|
|
368
411
|
end
|
|
369
412
|
|
|
370
413
|
def disable_percentage_of_requests
|
|
371
|
-
|
|
414
|
+
record_change('disable_percentage_of_requests', targeting_change(:percentage_requests)) do
|
|
415
|
+
disable_targeting(:percentage_requests)
|
|
416
|
+
end
|
|
372
417
|
true
|
|
373
418
|
end
|
|
374
419
|
|
|
375
420
|
def enable_for_date_range(start_date, end_date)
|
|
376
|
-
|
|
421
|
+
record_change('enable_for_date_range',
|
|
422
|
+
targeting_change(:date_range, added: { start: start_date, end: end_date })) do
|
|
423
|
+
enable_targeting(:date_range, { start: start_date, end: end_date })
|
|
424
|
+
end
|
|
377
425
|
true
|
|
378
426
|
end
|
|
379
427
|
|
|
380
428
|
def disable_date_range
|
|
381
|
-
|
|
429
|
+
record_change('disable_date_range', targeting_change(:date_range)) do
|
|
430
|
+
disable_targeting(:date_range)
|
|
431
|
+
end
|
|
382
432
|
true
|
|
383
433
|
end
|
|
384
434
|
|
|
385
435
|
def enable_for_ip_addresses(ip_addresses)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
436
|
+
ips = Array(ip_addresses).map(&:to_s)
|
|
437
|
+
record_change('enable_for_ip_addresses', targeting_change(:ip_address, added: ips)) do
|
|
438
|
+
# ip_address is stored as a flat Array of strings; bypass the generic
|
|
439
|
+
# enable_targeting path whose "array type" branch stringifies the
|
|
440
|
+
# incoming array into a single '["x.y.z"]' entry.
|
|
441
|
+
@targeting[:ip_address] ||= []
|
|
442
|
+
ips.each do |str|
|
|
443
|
+
@targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
|
|
444
|
+
end
|
|
445
|
+
save_targeting
|
|
393
446
|
end
|
|
394
|
-
save_targeting
|
|
395
447
|
true
|
|
396
448
|
end
|
|
397
449
|
|
|
398
450
|
def disable_ip_addresses
|
|
399
|
-
|
|
451
|
+
record_change('disable_ip_addresses', targeting_change(:ip_address)) do
|
|
452
|
+
disable_targeting(:ip_address)
|
|
453
|
+
end
|
|
400
454
|
true
|
|
401
455
|
end
|
|
402
456
|
|
|
403
457
|
def enable_for_custom_attribute(attribute_name, values, operator: :equals)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
458
|
+
change = targeting_change(:custom_attributes,
|
|
459
|
+
added: { attribute: attribute_name, values: Array(values), operator: operator })
|
|
460
|
+
record_change('enable_for_custom_attribute', change) do
|
|
461
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
462
|
+
custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
|
|
463
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
464
|
+
end
|
|
407
465
|
true
|
|
408
466
|
end
|
|
409
467
|
|
|
410
468
|
def disable_custom_attribute(attribute_name)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
469
|
+
record_change('disable_custom_attribute', targeting_change(:custom_attributes, removed: attribute_name)) do
|
|
470
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
471
|
+
custom_attrs.delete(attribute_name.to_sym)
|
|
472
|
+
if custom_attrs.empty?
|
|
473
|
+
disable_targeting(:custom_attributes)
|
|
474
|
+
else
|
|
475
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
476
|
+
end
|
|
417
477
|
end
|
|
418
478
|
true
|
|
419
479
|
end
|
|
@@ -422,34 +482,42 @@ module Magick
|
|
|
422
482
|
variants_array = Array(variants).map do |v|
|
|
423
483
|
v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0)
|
|
424
484
|
end
|
|
425
|
-
|
|
485
|
+
payload = variants_array.map(&:to_h)
|
|
426
486
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
487
|
+
record_change('set_variants', { variants: payload }) do
|
|
488
|
+
enable_targeting(:variants, payload)
|
|
489
|
+
|
|
490
|
+
# Rails 8+ event
|
|
491
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
492
|
+
Magick::Rails::Events.variant_set(name, variants: variants_array)
|
|
493
|
+
end
|
|
430
494
|
end
|
|
431
495
|
|
|
432
496
|
true
|
|
433
497
|
end
|
|
434
498
|
|
|
435
499
|
def add_dependency(dependency_name)
|
|
436
|
-
|
|
437
|
-
|
|
500
|
+
record_change('add_dependency', { dependency: { added: dependency_name.to_s } }) do
|
|
501
|
+
@dependencies ||= []
|
|
502
|
+
@dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
|
|
438
503
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
504
|
+
# Rails 8+ event
|
|
505
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
506
|
+
Magick::Rails::Events.dependency_added(name, dependency_name)
|
|
507
|
+
end
|
|
442
508
|
end
|
|
443
509
|
|
|
444
510
|
true
|
|
445
511
|
end
|
|
446
512
|
|
|
447
513
|
def remove_dependency(dependency_name)
|
|
448
|
-
|
|
514
|
+
record_change('remove_dependency', { dependency: { removed: dependency_name.to_s } }) do
|
|
515
|
+
@dependencies&.delete(dependency_name.to_s)
|
|
449
516
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
517
|
+
# Rails 8+ event
|
|
518
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
519
|
+
Magick::Rails::Events.dependency_removed(name, dependency_name)
|
|
520
|
+
end
|
|
453
521
|
end
|
|
454
522
|
|
|
455
523
|
true
|
|
@@ -501,90 +569,93 @@ module Magick
|
|
|
501
569
|
old_value = @stored_value
|
|
502
570
|
validate_value!(value)
|
|
503
571
|
|
|
504
|
-
|
|
505
|
-
data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
|
|
506
|
-
data['description'] = description if description
|
|
507
|
-
data['display_name'] = display_name if display_name
|
|
508
|
-
data['group'] = group if group
|
|
509
|
-
adapter_registry.set_all_data(name, data)
|
|
572
|
+
changes = { value: { from: old_value, to: value } }
|
|
510
573
|
|
|
511
|
-
|
|
512
|
-
|
|
574
|
+
record_change('set_value', changes, user_id: user_id) do
|
|
575
|
+
# Bulk write all metadata in a single adapter call instead of 7 separate calls
|
|
576
|
+
data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
|
|
577
|
+
data['description'] = description if description
|
|
578
|
+
data['display_name'] = display_name if display_name
|
|
579
|
+
data['group'] = group if group
|
|
580
|
+
adapter_registry.set_all_data(name, data)
|
|
513
581
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
registered = Magick.features[name]
|
|
517
|
-
registered.instance_variable_set(:@stored_value, value)
|
|
518
|
-
registered.instance_variable_set(:@stored_value_initialized, true)
|
|
519
|
-
registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
changes = { value: { from: old_value, to: value } }
|
|
582
|
+
@stored_value = value
|
|
583
|
+
@stored_value_initialized = true
|
|
523
584
|
|
|
524
|
-
|
|
525
|
-
name
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
585
|
+
# Update registered feature instance if it exists
|
|
586
|
+
if Magick.features.key?(name)
|
|
587
|
+
registered = Magick.features[name]
|
|
588
|
+
registered.instance_variable_set(:@stored_value, value)
|
|
589
|
+
registered.instance_variable_set(:@stored_value_initialized, true)
|
|
590
|
+
registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
|
|
591
|
+
end
|
|
530
592
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
593
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
594
|
+
Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
|
|
595
|
+
end
|
|
534
596
|
end
|
|
535
597
|
|
|
536
598
|
true
|
|
537
599
|
end
|
|
538
600
|
|
|
539
601
|
def enable(user_id: nil)
|
|
540
|
-
|
|
541
|
-
@targeting = {}
|
|
542
|
-
save_targeting
|
|
602
|
+
changes = { value: { from: @stored_value, to: true }, targeting: { cleared: true } }
|
|
543
603
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
604
|
+
record_change('enable', changes, user_id: user_id) do
|
|
605
|
+
# Clear all targeting to enable globally
|
|
606
|
+
@targeting = {}
|
|
607
|
+
save_targeting
|
|
608
|
+
|
|
609
|
+
case type
|
|
610
|
+
when :boolean
|
|
611
|
+
set_value(true, user_id: user_id)
|
|
612
|
+
when :string
|
|
613
|
+
raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
|
|
614
|
+
when :number
|
|
615
|
+
raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
|
|
616
|
+
else
|
|
617
|
+
raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
|
|
618
|
+
end
|
|
554
619
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
620
|
+
# Rails 8+ event
|
|
621
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
622
|
+
Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
|
|
623
|
+
end
|
|
558
624
|
end
|
|
559
625
|
|
|
560
626
|
true
|
|
561
627
|
end
|
|
562
628
|
|
|
563
629
|
def disable(user_id: nil)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
save_targeting
|
|
630
|
+
disabled_value = { boolean: false, string: '', number: 0 }[type]
|
|
631
|
+
changes = { value: { from: @stored_value, to: disabled_value }, targeting: { cleared: true } }
|
|
567
632
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
633
|
+
record_change('disable', changes, user_id: user_id) do
|
|
634
|
+
# Clear all targeting to disable globally
|
|
635
|
+
@targeting = {}
|
|
636
|
+
save_targeting
|
|
637
|
+
|
|
638
|
+
case type
|
|
639
|
+
when :boolean
|
|
640
|
+
set_value(false, user_id: user_id)
|
|
641
|
+
when :string
|
|
642
|
+
set_value('', user_id: user_id)
|
|
643
|
+
when :number
|
|
644
|
+
set_value(0, user_id: user_id)
|
|
645
|
+
else
|
|
646
|
+
raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
|
|
647
|
+
end
|
|
578
648
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
649
|
+
# Ensure registered feature instance also has targeting cleared
|
|
650
|
+
if Magick.features.key?(name)
|
|
651
|
+
registered = Magick.features[name]
|
|
652
|
+
registered.instance_variable_set(:@targeting, {})
|
|
653
|
+
end
|
|
584
654
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
655
|
+
# Rails 8+ event
|
|
656
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
657
|
+
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
658
|
+
end
|
|
588
659
|
end
|
|
589
660
|
|
|
590
661
|
true
|
|
@@ -593,34 +664,41 @@ module Magick
|
|
|
593
664
|
def set_status(new_status)
|
|
594
665
|
raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym)
|
|
595
666
|
|
|
596
|
-
@status
|
|
597
|
-
|
|
667
|
+
record_change('set_status', { status: { from: @status, to: new_status.to_sym } }) do
|
|
668
|
+
@status = new_status.to_sym
|
|
669
|
+
adapter_registry.set(name, 'status', status)
|
|
670
|
+
end
|
|
598
671
|
true
|
|
599
672
|
end
|
|
600
673
|
|
|
601
674
|
def set_group(group_name)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
@group = group_name.to_s.strip
|
|
675
|
+
new_group = group_name.nil? || group_name.to_s.strip.empty? ? nil : group_name.to_s.strip
|
|
676
|
+
|
|
677
|
+
record_change('set_group', { group: { from: @group, to: new_group } }) do
|
|
678
|
+
@group = new_group
|
|
679
|
+
# Clear group from adapter by setting to nil (adapters handle this)
|
|
608
680
|
adapter_registry.set(name, 'group', @group)
|
|
609
|
-
end
|
|
610
681
|
|
|
611
|
-
|
|
612
|
-
|
|
682
|
+
# Update registered feature instance if it exists
|
|
683
|
+
Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
|
|
684
|
+
end
|
|
613
685
|
|
|
614
686
|
true
|
|
615
687
|
end
|
|
616
688
|
|
|
617
689
|
def delete
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
690
|
+
# Snapshot before the wipe so the recorded version captures the state
|
|
691
|
+
# this feature had when it was deleted, not the emptied one.
|
|
692
|
+
snapshot = to_h
|
|
693
|
+
|
|
694
|
+
record_change('delete', { deleted: true }, snapshot: snapshot) do
|
|
695
|
+
adapter_registry.delete(name)
|
|
696
|
+
@stored_value = nil
|
|
697
|
+
@stored_value_initialized = false # Reset initialization flag so get_value returns default_value
|
|
698
|
+
@targeting = {}
|
|
699
|
+
# Also remove from Magick.features if registered
|
|
700
|
+
Magick.features.delete(name.to_s)
|
|
701
|
+
end
|
|
624
702
|
true
|
|
625
703
|
end
|
|
626
704
|
|
|
@@ -672,6 +750,64 @@ module Magick
|
|
|
672
750
|
}
|
|
673
751
|
end
|
|
674
752
|
|
|
753
|
+
# Wire-format serializer for control-plane APIs (e.g. the platform's
|
|
754
|
+
# /internal/panel/flags endpoints). The "targeting" key is ALWAYS present
|
|
755
|
+
# ({} = no targeting), array rules are arrays of strings, percentages are
|
|
756
|
+
# floats, and the internal :variants entry never appears inside targeting.
|
|
757
|
+
# Rails-idiomatic: `render json: feature` (or a collection) emits this.
|
|
758
|
+
def as_json(_options = nil)
|
|
759
|
+
{
|
|
760
|
+
'name' => name,
|
|
761
|
+
'display_name' => display_name,
|
|
762
|
+
'group' => group,
|
|
763
|
+
'type' => type.to_s,
|
|
764
|
+
'status' => status.to_s,
|
|
765
|
+
'value' => stored_value,
|
|
766
|
+
'default_value' => default_value,
|
|
767
|
+
'description' => description,
|
|
768
|
+
'targeting' => TargetingPayload.serialize(targeting),
|
|
769
|
+
'dependencies' => (@dependencies || []).map(&:to_s),
|
|
770
|
+
# Variants live inside @targeting under the internal :variants key
|
|
771
|
+
# (variants_for_export reads a never-assigned ivar and is always
|
|
772
|
+
# empty), so the wire payload reads the authoritative source.
|
|
773
|
+
'variants' => TargetingPayload.deep_stringify(targeting[:variants] || [])
|
|
774
|
+
}
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Wholesale, declarative targeting write: the payload IS the new
|
|
778
|
+
# targeting state. Keys absent from it are removed; {} clears all
|
|
779
|
+
# targeting. Accepts wire input leniently (string/symbol keys, plural
|
|
780
|
+
# aliases, scalars for lists, numeric strings) but validates strictly —
|
|
781
|
+
# unknown keys or invalid values raise InvalidTargetingError before any
|
|
782
|
+
# state is touched. The internal :variants entry is not part of the wire
|
|
783
|
+
# payload and survives the replace untouched.
|
|
784
|
+
# Accepts the payload as a positional hash or inline keywords
|
|
785
|
+
# (replace_targeting(user: [3])) — Ruby routes a braceless hash to
|
|
786
|
+
# keywords, so both spellings must land in the same place. Passing
|
|
787
|
+
# nothing raises (via normalize): clearing requires an explicit {}.
|
|
788
|
+
def replace_targeting(payload = nil, user_id: nil, **inline_rules)
|
|
789
|
+
raise ArgumentError, 'pass targeting either as a hash or inline, not both' if payload && inline_rules.any?
|
|
790
|
+
|
|
791
|
+
normalized = TargetingPayload.normalize(payload || (inline_rules unless inline_rules.empty?))
|
|
792
|
+
normalized[:variants] = targeting[:variants] if targeting[:variants]
|
|
793
|
+
|
|
794
|
+
changes = {
|
|
795
|
+
targeting: {
|
|
796
|
+
from: TargetingPayload.serialize(targeting),
|
|
797
|
+
to: TargetingPayload.serialize(normalized)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
record_change('replace_targeting', changes, user_id: user_id) do
|
|
801
|
+
@targeting = normalized
|
|
802
|
+
persist_targeting
|
|
803
|
+
|
|
804
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
805
|
+
Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
true
|
|
809
|
+
end
|
|
810
|
+
|
|
675
811
|
def variants_for_export
|
|
676
812
|
return [] unless defined?(Magick::FeatureVariant)
|
|
677
813
|
|
|
@@ -686,6 +822,67 @@ module Magick
|
|
|
686
822
|
end
|
|
687
823
|
|
|
688
824
|
def save_targeting
|
|
825
|
+
# Records only when called directly (e.g. Admin UI clearing variants);
|
|
826
|
+
# when reached through a wrapping mutator the guard is active and this
|
|
827
|
+
# just persists.
|
|
828
|
+
record_change('update_targeting', { targeting: targeting.dup }) do
|
|
829
|
+
persist_targeting
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# Restore full feature state from a version snapshot (used by
|
|
834
|
+
# Versioning#rollback). Replaces value (including false/empty), status,
|
|
835
|
+
# group, the entire targeting hash and dependencies wholesale.
|
|
836
|
+
def restore_snapshot!(data)
|
|
837
|
+
set_status(data[:status]) if data[:status]
|
|
838
|
+
set_group(data[:group]) if data.key?(:group)
|
|
839
|
+
|
|
840
|
+
value = data[:value]
|
|
841
|
+
set_value(cast_value(value)) unless value.nil?
|
|
842
|
+
|
|
843
|
+
@targeting = normalize_targeting(data[:targeting])
|
|
844
|
+
save_targeting
|
|
845
|
+
|
|
846
|
+
@dependencies = Array(data[:dependencies]).map(&:to_s)
|
|
847
|
+
true
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
private
|
|
851
|
+
|
|
852
|
+
# Single choke point for change tracking: wraps a public mutator's body,
|
|
853
|
+
# then writes one audit entry and one version snapshot for the operation.
|
|
854
|
+
# A thread-local guard makes nested mutator calls (enable -> set_value)
|
|
855
|
+
# silent, so one logical operation records exactly once, under its real
|
|
856
|
+
# action name. No-op while definitions are being (re)applied at boot.
|
|
857
|
+
def record_change(action, changes = {}, user_id: nil, snapshot: nil)
|
|
858
|
+
if Magick.change_recording_suppressed?
|
|
859
|
+
return block_given? ? yield : true
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
result = block_given? ? Magick.suppress_change_recording { yield } : true
|
|
863
|
+
|
|
864
|
+
actor = user_id || Magick.current_actor
|
|
865
|
+
Magick.audit_log&.log(name, action, user_id: actor, changes: changes)
|
|
866
|
+
|
|
867
|
+
if Magick.versioning_enabled?
|
|
868
|
+
begin
|
|
869
|
+
Magick.versioning&.record_change(self, action: action, created_by: actor, snapshot: snapshot)
|
|
870
|
+
rescue StandardError => e
|
|
871
|
+
warn "Magick: Failed to record version for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
result
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def targeting_change(type, added: nil, removed: nil)
|
|
879
|
+
change = { type: type }
|
|
880
|
+
change[:added] = added unless added.nil?
|
|
881
|
+
change[:removed] = removed unless removed.nil?
|
|
882
|
+
{ targeting: change }
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def persist_targeting
|
|
689
886
|
# Save targeting to adapter (this updates memory synchronously, then Redis/AR)
|
|
690
887
|
# The set method already publishes cache invalidation to other processes via Pub/Sub
|
|
691
888
|
adapter_registry.set(name, 'targeting', targeting)
|
|
@@ -706,10 +903,6 @@ module Magick
|
|
|
706
903
|
# 3. The set method handles both sync and async Redis updates correctly
|
|
707
904
|
end
|
|
708
905
|
|
|
709
|
-
private
|
|
710
|
-
|
|
711
|
-
attr_reader :targeting
|
|
712
|
-
|
|
713
906
|
def stored_value
|
|
714
907
|
return @stored_value if @stored_value_initialized
|
|
715
908
|
|
|
@@ -748,14 +941,7 @@ module Magick
|
|
|
748
941
|
group_value = all_data['group']
|
|
749
942
|
@group = group_value if group_value
|
|
750
943
|
|
|
751
|
-
|
|
752
|
-
if targeting_value.is_a?(Hash)
|
|
753
|
-
@targeting = targeting_value.transform_keys(&:to_sym)
|
|
754
|
-
@targeting[:percentage_users] = @targeting[:percentage_users].to_f if @targeting[:percentage_users]
|
|
755
|
-
@targeting[:percentage_requests] = @targeting[:percentage_requests].to_f if @targeting[:percentage_requests]
|
|
756
|
-
else
|
|
757
|
-
@targeting = {}
|
|
758
|
-
end
|
|
944
|
+
@targeting = normalize_targeting(all_data['targeting'])
|
|
759
945
|
|
|
760
946
|
# Track what was loaded so save_metadata_if_new can skip unnecessary writes
|
|
761
947
|
@_loaded_description = all_data['description']
|
|
@@ -763,6 +949,15 @@ module Magick
|
|
|
763
949
|
@_loaded_group = all_data['group']
|
|
764
950
|
end
|
|
765
951
|
|
|
952
|
+
def normalize_targeting(raw)
|
|
953
|
+
return {} unless raw.is_a?(Hash)
|
|
954
|
+
|
|
955
|
+
normalized = raw.transform_keys(&:to_sym)
|
|
956
|
+
normalized[:percentage_users] = normalized[:percentage_users].to_f if normalized[:percentage_users]
|
|
957
|
+
normalized[:percentage_requests] = normalized[:percentage_requests].to_f if normalized[:percentage_requests]
|
|
958
|
+
normalized
|
|
959
|
+
end
|
|
960
|
+
|
|
766
961
|
def save_metadata_if_new
|
|
767
962
|
# Only write to adapter if the DSL value differs from what was loaded
|
|
768
963
|
# This avoids unnecessary find_or_initialize_by + save! calls on every boot
|