magick-feature-flags 1.4.2 → 1.5.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 +71 -7
- data/app/controllers/magick/adminui/features_controller.rb +46 -0
- data/config/magick.rb.example +5 -3
- data/lib/generators/magick/install/templates/magick.rb +5 -3
- data/lib/magick/adapters/registry.rb +138 -55
- data/lib/magick/admin_ui.rb +5 -1
- data/lib/magick/config.rb +10 -4
- data/lib/magick/feature.rb +313 -166
- data/lib/magick/rails/railtie.rb +37 -2
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +205 -51
- data/lib/magick.rb +61 -0
- metadata +1 -1
data/lib/magick/feature.rb
CHANGED
|
@@ -233,187 +233,246 @@ module Magick
|
|
|
233
233
|
end
|
|
234
234
|
|
|
235
235
|
def enable_for_user(user_id)
|
|
236
|
-
|
|
236
|
+
record_change('enable_for_user', targeting_change(:user, added: user_id)) do
|
|
237
|
+
enable_targeting(:user, user_id)
|
|
238
|
+
end
|
|
237
239
|
true
|
|
238
240
|
end
|
|
239
241
|
|
|
240
242
|
def disable_for_user(user_id)
|
|
241
|
-
|
|
243
|
+
record_change('disable_for_user', targeting_change(:user, removed: user_id)) do
|
|
244
|
+
disable_targeting(:user, user_id)
|
|
245
|
+
end
|
|
242
246
|
true
|
|
243
247
|
end
|
|
244
248
|
|
|
245
249
|
def enable_for_group(group_name)
|
|
246
|
-
|
|
250
|
+
record_change('enable_for_group', targeting_change(:group, added: group_name)) do
|
|
251
|
+
enable_targeting(:group, group_name)
|
|
252
|
+
end
|
|
247
253
|
true
|
|
248
254
|
end
|
|
249
255
|
|
|
250
256
|
def disable_for_group(group_name)
|
|
251
|
-
|
|
257
|
+
record_change('disable_for_group', targeting_change(:group, removed: group_name)) do
|
|
258
|
+
disable_targeting(:group, group_name)
|
|
259
|
+
end
|
|
252
260
|
true
|
|
253
261
|
end
|
|
254
262
|
|
|
255
263
|
def enable_for_role(role_name)
|
|
256
|
-
|
|
264
|
+
record_change('enable_for_role', targeting_change(:role, added: role_name)) do
|
|
265
|
+
enable_targeting(:role, role_name)
|
|
266
|
+
end
|
|
257
267
|
true
|
|
258
268
|
end
|
|
259
269
|
|
|
260
270
|
def disable_for_role(role_name)
|
|
261
|
-
|
|
271
|
+
record_change('disable_for_role', targeting_change(:role, removed: role_name)) do
|
|
272
|
+
disable_targeting(:role, role_name)
|
|
273
|
+
end
|
|
262
274
|
true
|
|
263
275
|
end
|
|
264
276
|
|
|
265
277
|
def enable_for_tag(tag_name)
|
|
266
|
-
|
|
278
|
+
record_change('enable_for_tag', targeting_change(:tag, added: tag_name)) do
|
|
279
|
+
enable_targeting(:tag, tag_name)
|
|
280
|
+
end
|
|
267
281
|
true
|
|
268
282
|
end
|
|
269
283
|
|
|
270
284
|
def disable_for_tag(tag_name)
|
|
271
|
-
|
|
285
|
+
record_change('disable_for_tag', targeting_change(:tag, removed: tag_name)) do
|
|
286
|
+
disable_targeting(:tag, tag_name)
|
|
287
|
+
end
|
|
272
288
|
true
|
|
273
289
|
end
|
|
274
290
|
|
|
275
291
|
# --- Exclusion methods ---
|
|
276
292
|
|
|
277
293
|
def exclude_user(user_id)
|
|
278
|
-
|
|
294
|
+
record_change('exclude_user', targeting_change(:excluded_users, added: user_id)) do
|
|
295
|
+
enable_targeting(:excluded_users, user_id)
|
|
296
|
+
end
|
|
279
297
|
true
|
|
280
298
|
end
|
|
281
299
|
|
|
282
300
|
def remove_user_exclusion(user_id)
|
|
283
|
-
|
|
301
|
+
record_change('remove_user_exclusion', targeting_change(:excluded_users, removed: user_id)) do
|
|
302
|
+
disable_targeting(:excluded_users, user_id)
|
|
303
|
+
end
|
|
284
304
|
true
|
|
285
305
|
end
|
|
286
306
|
|
|
287
307
|
def exclude_tag(tag_name)
|
|
288
|
-
|
|
308
|
+
record_change('exclude_tag', targeting_change(:excluded_tags, added: tag_name)) do
|
|
309
|
+
enable_targeting(:excluded_tags, tag_name)
|
|
310
|
+
end
|
|
289
311
|
true
|
|
290
312
|
end
|
|
291
313
|
|
|
292
314
|
def remove_tag_exclusion(tag_name)
|
|
293
|
-
|
|
315
|
+
record_change('remove_tag_exclusion', targeting_change(:excluded_tags, removed: tag_name)) do
|
|
316
|
+
disable_targeting(:excluded_tags, tag_name)
|
|
317
|
+
end
|
|
294
318
|
true
|
|
295
319
|
end
|
|
296
320
|
|
|
297
321
|
def exclude_group(group_name)
|
|
298
|
-
|
|
322
|
+
record_change('exclude_group', targeting_change(:excluded_groups, added: group_name)) do
|
|
323
|
+
enable_targeting(:excluded_groups, group_name)
|
|
324
|
+
end
|
|
299
325
|
true
|
|
300
326
|
end
|
|
301
327
|
|
|
302
328
|
def remove_group_exclusion(group_name)
|
|
303
|
-
|
|
329
|
+
record_change('remove_group_exclusion', targeting_change(:excluded_groups, removed: group_name)) do
|
|
330
|
+
disable_targeting(:excluded_groups, group_name)
|
|
331
|
+
end
|
|
304
332
|
true
|
|
305
333
|
end
|
|
306
334
|
|
|
307
335
|
def exclude_role(role_name)
|
|
308
|
-
|
|
336
|
+
record_change('exclude_role', targeting_change(:excluded_roles, added: role_name)) do
|
|
337
|
+
enable_targeting(:excluded_roles, role_name)
|
|
338
|
+
end
|
|
309
339
|
true
|
|
310
340
|
end
|
|
311
341
|
|
|
312
342
|
def remove_role_exclusion(role_name)
|
|
313
|
-
|
|
343
|
+
record_change('remove_role_exclusion', targeting_change(:excluded_roles, removed: role_name)) do
|
|
344
|
+
disable_targeting(:excluded_roles, role_name)
|
|
345
|
+
end
|
|
314
346
|
true
|
|
315
347
|
end
|
|
316
348
|
|
|
317
349
|
def exclude_ip_addresses(ip_addresses)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
350
|
+
ips = Array(ip_addresses).map(&:to_s)
|
|
351
|
+
record_change('exclude_ip_addresses', targeting_change(:excluded_ip_addresses, added: ips)) do
|
|
352
|
+
# excluded_ip_addresses is stored as a flat Array of strings; bypass the
|
|
353
|
+
# generic enable_targeting path whose "array type" branch stringifies the
|
|
354
|
+
# incoming array into a single element.
|
|
355
|
+
@targeting[:excluded_ip_addresses] ||= []
|
|
356
|
+
ips.each do |str|
|
|
357
|
+
@targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
|
|
358
|
+
end
|
|
359
|
+
save_targeting
|
|
325
360
|
end
|
|
326
|
-
save_targeting
|
|
327
361
|
true
|
|
328
362
|
end
|
|
329
363
|
|
|
330
364
|
def remove_ip_exclusion
|
|
331
|
-
|
|
365
|
+
record_change('remove_ip_exclusion', targeting_change(:excluded_ip_addresses)) do
|
|
366
|
+
disable_targeting(:excluded_ip_addresses)
|
|
367
|
+
end
|
|
332
368
|
true
|
|
333
369
|
end
|
|
334
370
|
|
|
335
371
|
def enable_percentage_of_users(percentage)
|
|
336
|
-
|
|
337
|
-
|
|
372
|
+
record_change('enable_percentage_of_users', targeting_change(:percentage_users, added: percentage.to_f)) do
|
|
373
|
+
@targeting[:percentage_users] = percentage.to_f
|
|
374
|
+
save_targeting
|
|
338
375
|
|
|
339
|
-
|
|
340
|
-
|
|
376
|
+
# Update registered feature instance if it exists
|
|
377
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
341
378
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
379
|
+
# Rails 8+ event
|
|
380
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
381
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
|
|
382
|
+
end
|
|
345
383
|
end
|
|
346
384
|
|
|
347
385
|
true
|
|
348
386
|
end
|
|
349
387
|
|
|
350
388
|
def disable_percentage_of_users
|
|
351
|
-
|
|
389
|
+
record_change('disable_percentage_of_users', targeting_change(:percentage_users)) do
|
|
390
|
+
disable_targeting(:percentage_users)
|
|
391
|
+
end
|
|
352
392
|
true
|
|
353
393
|
end
|
|
354
394
|
|
|
355
395
|
def enable_percentage_of_requests(percentage)
|
|
356
|
-
|
|
357
|
-
|
|
396
|
+
record_change('enable_percentage_of_requests', targeting_change(:percentage_requests, added: percentage.to_f)) do
|
|
397
|
+
@targeting[:percentage_requests] = percentage.to_f
|
|
398
|
+
save_targeting
|
|
358
399
|
|
|
359
|
-
|
|
360
|
-
|
|
400
|
+
# Update registered feature instance if it exists
|
|
401
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
361
402
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
403
|
+
# Rails 8+ event
|
|
404
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
405
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
|
|
406
|
+
end
|
|
365
407
|
end
|
|
366
408
|
|
|
367
409
|
true
|
|
368
410
|
end
|
|
369
411
|
|
|
370
412
|
def disable_percentage_of_requests
|
|
371
|
-
|
|
413
|
+
record_change('disable_percentage_of_requests', targeting_change(:percentage_requests)) do
|
|
414
|
+
disable_targeting(:percentage_requests)
|
|
415
|
+
end
|
|
372
416
|
true
|
|
373
417
|
end
|
|
374
418
|
|
|
375
419
|
def enable_for_date_range(start_date, end_date)
|
|
376
|
-
|
|
420
|
+
record_change('enable_for_date_range',
|
|
421
|
+
targeting_change(:date_range, added: { start: start_date, end: end_date })) do
|
|
422
|
+
enable_targeting(:date_range, { start: start_date, end: end_date })
|
|
423
|
+
end
|
|
377
424
|
true
|
|
378
425
|
end
|
|
379
426
|
|
|
380
427
|
def disable_date_range
|
|
381
|
-
|
|
428
|
+
record_change('disable_date_range', targeting_change(:date_range)) do
|
|
429
|
+
disable_targeting(:date_range)
|
|
430
|
+
end
|
|
382
431
|
true
|
|
383
432
|
end
|
|
384
433
|
|
|
385
434
|
def enable_for_ip_addresses(ip_addresses)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
435
|
+
ips = Array(ip_addresses).map(&:to_s)
|
|
436
|
+
record_change('enable_for_ip_addresses', targeting_change(:ip_address, added: ips)) do
|
|
437
|
+
# ip_address is stored as a flat Array of strings; bypass the generic
|
|
438
|
+
# enable_targeting path whose "array type" branch stringifies the
|
|
439
|
+
# incoming array into a single '["x.y.z"]' entry.
|
|
440
|
+
@targeting[:ip_address] ||= []
|
|
441
|
+
ips.each do |str|
|
|
442
|
+
@targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
|
|
443
|
+
end
|
|
444
|
+
save_targeting
|
|
393
445
|
end
|
|
394
|
-
save_targeting
|
|
395
446
|
true
|
|
396
447
|
end
|
|
397
448
|
|
|
398
449
|
def disable_ip_addresses
|
|
399
|
-
|
|
450
|
+
record_change('disable_ip_addresses', targeting_change(:ip_address)) do
|
|
451
|
+
disable_targeting(:ip_address)
|
|
452
|
+
end
|
|
400
453
|
true
|
|
401
454
|
end
|
|
402
455
|
|
|
403
456
|
def enable_for_custom_attribute(attribute_name, values, operator: :equals)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
457
|
+
change = targeting_change(:custom_attributes,
|
|
458
|
+
added: { attribute: attribute_name, values: Array(values), operator: operator })
|
|
459
|
+
record_change('enable_for_custom_attribute', change) do
|
|
460
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
461
|
+
custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
|
|
462
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
463
|
+
end
|
|
407
464
|
true
|
|
408
465
|
end
|
|
409
466
|
|
|
410
467
|
def disable_custom_attribute(attribute_name)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
468
|
+
record_change('disable_custom_attribute', targeting_change(:custom_attributes, removed: attribute_name)) do
|
|
469
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
470
|
+
custom_attrs.delete(attribute_name.to_sym)
|
|
471
|
+
if custom_attrs.empty?
|
|
472
|
+
disable_targeting(:custom_attributes)
|
|
473
|
+
else
|
|
474
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
475
|
+
end
|
|
417
476
|
end
|
|
418
477
|
true
|
|
419
478
|
end
|
|
@@ -422,34 +481,42 @@ module Magick
|
|
|
422
481
|
variants_array = Array(variants).map do |v|
|
|
423
482
|
v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0)
|
|
424
483
|
end
|
|
425
|
-
|
|
484
|
+
payload = variants_array.map(&:to_h)
|
|
426
485
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
486
|
+
record_change('set_variants', { variants: payload }) do
|
|
487
|
+
enable_targeting(:variants, payload)
|
|
488
|
+
|
|
489
|
+
# Rails 8+ event
|
|
490
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
491
|
+
Magick::Rails::Events.variant_set(name, variants: variants_array)
|
|
492
|
+
end
|
|
430
493
|
end
|
|
431
494
|
|
|
432
495
|
true
|
|
433
496
|
end
|
|
434
497
|
|
|
435
498
|
def add_dependency(dependency_name)
|
|
436
|
-
|
|
437
|
-
|
|
499
|
+
record_change('add_dependency', { dependency: { added: dependency_name.to_s } }) do
|
|
500
|
+
@dependencies ||= []
|
|
501
|
+
@dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
|
|
438
502
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
503
|
+
# Rails 8+ event
|
|
504
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
505
|
+
Magick::Rails::Events.dependency_added(name, dependency_name)
|
|
506
|
+
end
|
|
442
507
|
end
|
|
443
508
|
|
|
444
509
|
true
|
|
445
510
|
end
|
|
446
511
|
|
|
447
512
|
def remove_dependency(dependency_name)
|
|
448
|
-
|
|
513
|
+
record_change('remove_dependency', { dependency: { removed: dependency_name.to_s } }) do
|
|
514
|
+
@dependencies&.delete(dependency_name.to_s)
|
|
449
515
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
516
|
+
# Rails 8+ event
|
|
517
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
518
|
+
Magick::Rails::Events.dependency_removed(name, dependency_name)
|
|
519
|
+
end
|
|
453
520
|
end
|
|
454
521
|
|
|
455
522
|
true
|
|
@@ -501,90 +568,93 @@ module Magick
|
|
|
501
568
|
old_value = @stored_value
|
|
502
569
|
validate_value!(value)
|
|
503
570
|
|
|
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)
|
|
510
|
-
|
|
511
|
-
@stored_value = value
|
|
512
|
-
@stored_value_initialized = true
|
|
571
|
+
changes = { value: { from: old_value, to: value } }
|
|
513
572
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
573
|
+
record_change('set_value', changes, user_id: user_id) do
|
|
574
|
+
# Bulk write all metadata in a single adapter call instead of 7 separate calls
|
|
575
|
+
data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
|
|
576
|
+
data['description'] = description if description
|
|
577
|
+
data['display_name'] = display_name if display_name
|
|
578
|
+
data['group'] = group if group
|
|
579
|
+
adapter_registry.set_all_data(name, data)
|
|
521
580
|
|
|
522
|
-
|
|
581
|
+
@stored_value = value
|
|
582
|
+
@stored_value_initialized = true
|
|
523
583
|
|
|
524
|
-
|
|
525
|
-
name
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
584
|
+
# Update registered feature instance if it exists
|
|
585
|
+
if Magick.features.key?(name)
|
|
586
|
+
registered = Magick.features[name]
|
|
587
|
+
registered.instance_variable_set(:@stored_value, value)
|
|
588
|
+
registered.instance_variable_set(:@stored_value_initialized, true)
|
|
589
|
+
registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
|
|
590
|
+
end
|
|
530
591
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
592
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
593
|
+
Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
|
|
594
|
+
end
|
|
534
595
|
end
|
|
535
596
|
|
|
536
597
|
true
|
|
537
598
|
end
|
|
538
599
|
|
|
539
600
|
def enable(user_id: nil)
|
|
540
|
-
|
|
541
|
-
@targeting = {}
|
|
542
|
-
save_targeting
|
|
601
|
+
changes = { value: { from: @stored_value, to: true }, targeting: { cleared: true } }
|
|
543
602
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
603
|
+
record_change('enable', changes, user_id: user_id) do
|
|
604
|
+
# Clear all targeting to enable globally
|
|
605
|
+
@targeting = {}
|
|
606
|
+
save_targeting
|
|
607
|
+
|
|
608
|
+
case type
|
|
609
|
+
when :boolean
|
|
610
|
+
set_value(true, user_id: user_id)
|
|
611
|
+
when :string
|
|
612
|
+
raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
|
|
613
|
+
when :number
|
|
614
|
+
raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
|
|
615
|
+
else
|
|
616
|
+
raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
|
|
617
|
+
end
|
|
554
618
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
619
|
+
# Rails 8+ event
|
|
620
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
621
|
+
Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
|
|
622
|
+
end
|
|
558
623
|
end
|
|
559
624
|
|
|
560
625
|
true
|
|
561
626
|
end
|
|
562
627
|
|
|
563
628
|
def disable(user_id: nil)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
save_targeting
|
|
629
|
+
disabled_value = { boolean: false, string: '', number: 0 }[type]
|
|
630
|
+
changes = { value: { from: @stored_value, to: disabled_value }, targeting: { cleared: true } }
|
|
567
631
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
632
|
+
record_change('disable', changes, user_id: user_id) do
|
|
633
|
+
# Clear all targeting to disable globally
|
|
634
|
+
@targeting = {}
|
|
635
|
+
save_targeting
|
|
636
|
+
|
|
637
|
+
case type
|
|
638
|
+
when :boolean
|
|
639
|
+
set_value(false, user_id: user_id)
|
|
640
|
+
when :string
|
|
641
|
+
set_value('', user_id: user_id)
|
|
642
|
+
when :number
|
|
643
|
+
set_value(0, user_id: user_id)
|
|
644
|
+
else
|
|
645
|
+
raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
|
|
646
|
+
end
|
|
578
647
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
648
|
+
# Ensure registered feature instance also has targeting cleared
|
|
649
|
+
if Magick.features.key?(name)
|
|
650
|
+
registered = Magick.features[name]
|
|
651
|
+
registered.instance_variable_set(:@targeting, {})
|
|
652
|
+
end
|
|
584
653
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
654
|
+
# Rails 8+ event
|
|
655
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
656
|
+
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
657
|
+
end
|
|
588
658
|
end
|
|
589
659
|
|
|
590
660
|
true
|
|
@@ -593,37 +663,53 @@ module Magick
|
|
|
593
663
|
def set_status(new_status)
|
|
594
664
|
raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym)
|
|
595
665
|
|
|
596
|
-
@status
|
|
597
|
-
|
|
666
|
+
record_change('set_status', { status: { from: @status, to: new_status.to_sym } }) do
|
|
667
|
+
@status = new_status.to_sym
|
|
668
|
+
adapter_registry.set(name, 'status', status)
|
|
669
|
+
end
|
|
598
670
|
true
|
|
599
671
|
end
|
|
600
672
|
|
|
601
673
|
def set_group(group_name)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
@group = group_name.to_s.strip
|
|
674
|
+
new_group = group_name.nil? || group_name.to_s.strip.empty? ? nil : group_name.to_s.strip
|
|
675
|
+
|
|
676
|
+
record_change('set_group', { group: { from: @group, to: new_group } }) do
|
|
677
|
+
@group = new_group
|
|
678
|
+
# Clear group from adapter by setting to nil (adapters handle this)
|
|
608
679
|
adapter_registry.set(name, 'group', @group)
|
|
609
|
-
end
|
|
610
680
|
|
|
611
|
-
|
|
612
|
-
|
|
681
|
+
# Update registered feature instance if it exists
|
|
682
|
+
Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
|
|
683
|
+
end
|
|
613
684
|
|
|
614
685
|
true
|
|
615
686
|
end
|
|
616
687
|
|
|
617
688
|
def delete
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
689
|
+
# Snapshot before the wipe so the recorded version captures the state
|
|
690
|
+
# this feature had when it was deleted, not the emptied one.
|
|
691
|
+
snapshot = to_h
|
|
692
|
+
|
|
693
|
+
record_change('delete', { deleted: true }, snapshot: snapshot) do
|
|
694
|
+
adapter_registry.delete(name)
|
|
695
|
+
@stored_value = nil
|
|
696
|
+
@stored_value_initialized = false # Reset initialization flag so get_value returns default_value
|
|
697
|
+
@targeting = {}
|
|
698
|
+
# Also remove from Magick.features if registered
|
|
699
|
+
Magick.features.delete(name.to_s)
|
|
700
|
+
end
|
|
624
701
|
true
|
|
625
702
|
end
|
|
626
703
|
|
|
704
|
+
# Reload feature state from the shared, authoritative backend (ActiveRecord/
|
|
705
|
+
# Redis), bypassing this process's local in-process memory cache. Used by the
|
|
706
|
+
# Admin UI so a toggle performed on another process/container is reflected
|
|
707
|
+
# immediately, without waiting for Pub/Sub cache invalidation to arrive.
|
|
708
|
+
def reload_from_source!
|
|
709
|
+
adapter_registry.authoritative_get_all_data(name) if adapter_registry.respond_to?(:authoritative_get_all_data)
|
|
710
|
+
reload
|
|
711
|
+
end
|
|
712
|
+
|
|
627
713
|
# Reload feature state from adapter (useful when feature is changed externally)
|
|
628
714
|
def reload
|
|
629
715
|
load_from_adapter
|
|
@@ -677,6 +763,69 @@ module Magick
|
|
|
677
763
|
end
|
|
678
764
|
|
|
679
765
|
def save_targeting
|
|
766
|
+
# Records only when called directly (e.g. Admin UI clearing variants);
|
|
767
|
+
# when reached through a wrapping mutator the guard is active and this
|
|
768
|
+
# just persists.
|
|
769
|
+
record_change('update_targeting', { targeting: targeting.dup }) do
|
|
770
|
+
persist_targeting
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Restore full feature state from a version snapshot (used by
|
|
775
|
+
# Versioning#rollback). Replaces value (including false/empty), status,
|
|
776
|
+
# group, the entire targeting hash and dependencies wholesale.
|
|
777
|
+
def restore_snapshot!(data)
|
|
778
|
+
set_status(data[:status]) if data[:status]
|
|
779
|
+
set_group(data[:group]) if data.key?(:group)
|
|
780
|
+
|
|
781
|
+
value = data[:value]
|
|
782
|
+
set_value(cast_value(value)) unless value.nil?
|
|
783
|
+
|
|
784
|
+
@targeting = normalize_targeting(data[:targeting])
|
|
785
|
+
save_targeting
|
|
786
|
+
|
|
787
|
+
@dependencies = Array(data[:dependencies]).map(&:to_s)
|
|
788
|
+
true
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
private
|
|
792
|
+
|
|
793
|
+
attr_reader :targeting
|
|
794
|
+
|
|
795
|
+
# Single choke point for change tracking: wraps a public mutator's body,
|
|
796
|
+
# then writes one audit entry and one version snapshot for the operation.
|
|
797
|
+
# A thread-local guard makes nested mutator calls (enable -> set_value)
|
|
798
|
+
# silent, so one logical operation records exactly once, under its real
|
|
799
|
+
# action name. No-op while definitions are being (re)applied at boot.
|
|
800
|
+
def record_change(action, changes = {}, user_id: nil, snapshot: nil)
|
|
801
|
+
if Magick.change_recording_suppressed?
|
|
802
|
+
return block_given? ? yield : true
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
result = block_given? ? Magick.suppress_change_recording { yield } : true
|
|
806
|
+
|
|
807
|
+
actor = user_id || Magick.current_actor
|
|
808
|
+
Magick.audit_log&.log(name, action, user_id: actor, changes: changes)
|
|
809
|
+
|
|
810
|
+
if Magick.versioning_enabled?
|
|
811
|
+
begin
|
|
812
|
+
Magick.versioning&.record_change(self, action: action, created_by: actor, snapshot: snapshot)
|
|
813
|
+
rescue StandardError => e
|
|
814
|
+
warn "Magick: Failed to record version for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
result
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def targeting_change(type, added: nil, removed: nil)
|
|
822
|
+
change = { type: type }
|
|
823
|
+
change[:added] = added unless added.nil?
|
|
824
|
+
change[:removed] = removed unless removed.nil?
|
|
825
|
+
{ targeting: change }
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def persist_targeting
|
|
680
829
|
# Save targeting to adapter (this updates memory synchronously, then Redis/AR)
|
|
681
830
|
# The set method already publishes cache invalidation to other processes via Pub/Sub
|
|
682
831
|
adapter_registry.set(name, 'targeting', targeting)
|
|
@@ -697,10 +846,6 @@ module Magick
|
|
|
697
846
|
# 3. The set method handles both sync and async Redis updates correctly
|
|
698
847
|
end
|
|
699
848
|
|
|
700
|
-
private
|
|
701
|
-
|
|
702
|
-
attr_reader :targeting
|
|
703
|
-
|
|
704
849
|
def stored_value
|
|
705
850
|
return @stored_value if @stored_value_initialized
|
|
706
851
|
|
|
@@ -739,14 +884,7 @@ module Magick
|
|
|
739
884
|
group_value = all_data['group']
|
|
740
885
|
@group = group_value if group_value
|
|
741
886
|
|
|
742
|
-
|
|
743
|
-
if targeting_value.is_a?(Hash)
|
|
744
|
-
@targeting = targeting_value.transform_keys(&:to_sym)
|
|
745
|
-
@targeting[:percentage_users] = @targeting[:percentage_users].to_f if @targeting[:percentage_users]
|
|
746
|
-
@targeting[:percentage_requests] = @targeting[:percentage_requests].to_f if @targeting[:percentage_requests]
|
|
747
|
-
else
|
|
748
|
-
@targeting = {}
|
|
749
|
-
end
|
|
887
|
+
@targeting = normalize_targeting(all_data['targeting'])
|
|
750
888
|
|
|
751
889
|
# Track what was loaded so save_metadata_if_new can skip unnecessary writes
|
|
752
890
|
@_loaded_description = all_data['description']
|
|
@@ -754,6 +892,15 @@ module Magick
|
|
|
754
892
|
@_loaded_group = all_data['group']
|
|
755
893
|
end
|
|
756
894
|
|
|
895
|
+
def normalize_targeting(raw)
|
|
896
|
+
return {} unless raw.is_a?(Hash)
|
|
897
|
+
|
|
898
|
+
normalized = raw.transform_keys(&:to_sym)
|
|
899
|
+
normalized[:percentage_users] = normalized[:percentage_users].to_f if normalized[:percentage_users]
|
|
900
|
+
normalized[:percentage_requests] = normalized[:percentage_requests].to_f if normalized[:percentage_requests]
|
|
901
|
+
normalized
|
|
902
|
+
end
|
|
903
|
+
|
|
757
904
|
def save_metadata_if_new
|
|
758
905
|
# Only write to adapter if the DSL value differs from what was loaded
|
|
759
906
|
# This avoids unnecessary find_or_initialize_by + save! calls on every boot
|