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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be6153e50823e724cefb980f60d0272c878ed9fd3619cfa98eaaa8917ab621d1
|
|
4
|
+
data.tar.gz: a6e50cabc9eb5730f819ff038ce23b9869e56140244d2b6b48a4c6efa46c1667
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d392122de52073465642db7d78302e4bd7aecb9d9507b9d2189f02c3bc9332b47c9facc02a5c1f7771d8bc355e25fe7607bd0c56a655536031bac6257b9a6cd0
|
|
7
|
+
data.tar.gz: 5feae702d0c3f67142b70ea65b1b396ea34c352430db81749eecdcb636481cbb87aff026dad4012c89e8875f4a6395edca61ca0669859039e6d7912ebd65375e
|
data/README.md
CHANGED
|
@@ -195,6 +195,40 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
|
|
|
195
195
|
feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
|
|
196
196
|
```
|
|
197
197
|
|
|
198
|
+
### Wire Targeting Payload (control-plane APIs)
|
|
199
|
+
|
|
200
|
+
For building flag-management endpoints on top of the gem (an internal panel,
|
|
201
|
+
a sync job, any JSON API), two primitives implement the wire contract — the
|
|
202
|
+
gem ships no routes, your app owns paths and auth:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# GET side — full flag payload, string keys. The "targeting" key is ALWAYS
|
|
206
|
+
# present ({} = no targeting); list rules are arrays of strings, percentages
|
|
207
|
+
# floats. Rails-idiomatic: works with render json: directly.
|
|
208
|
+
render json: Magick.features.values # [{... "targeting" => {"user" => ["3"], "percentage_users" => 50.0}}, ...]
|
|
209
|
+
|
|
210
|
+
# PATCH side — wholesale, declarative write: the payload IS the new targeting
|
|
211
|
+
# state. Keys absent from the payload are removed; {} clears everything.
|
|
212
|
+
feature.replace_targeting(payload['targeting'])
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`replace_targeting` is lenient about input spellings (string or symbol keys,
|
|
216
|
+
plural aliases like `users:`, scalars for lists, numeric strings) but strict
|
|
217
|
+
about content: unknown keys or invalid values (percentage outside `(0, 100]`,
|
|
218
|
+
malformed date ranges, junk IPs) raise `Magick::InvalidTargetingError`
|
|
219
|
+
*before anything is applied* — map it to a 422:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
rescue Magick::InvalidTargetingError => e
|
|
223
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Each call records one audit entry and one version snapshot
|
|
227
|
+
(`replace_targeting`). A/B variants are not part of the targeting payload —
|
|
228
|
+
they never appear inside the wire `targeting` object and survive a replace
|
|
229
|
+
untouched (manage them via `set_variants`). `enable`/`disable` still clear
|
|
230
|
+
all targeting wholesale, so their wire representation is `"targeting": {}`.
|
|
231
|
+
|
|
198
232
|
### Feature Exclusions
|
|
199
233
|
|
|
200
234
|
Exclusions let you block specific users, groups, roles, tags, or IP addresses from a feature — even if they match an inclusion rule. **Exclusions always take priority over inclusions.**
|
|
@@ -531,12 +565,58 @@ Magick.import(File.read('features.json'))
|
|
|
531
565
|
|
|
532
566
|
#### Versioning and Rollback
|
|
533
567
|
|
|
568
|
+
Every state-changing operation (value, status, group, targeting, exclusions,
|
|
569
|
+
variants, dependencies, delete) automatically records a version snapshot and
|
|
570
|
+
an audit entry — one per logical operation, under its real action name
|
|
571
|
+
(`enable`, `exclude_user`, `set_status`, …). Nested internals never
|
|
572
|
+
double-record.
|
|
573
|
+
|
|
534
574
|
```ruby
|
|
535
|
-
#
|
|
575
|
+
# History accumulates automatically:
|
|
576
|
+
Magick[:my_feature].enable # => version 1 (action: "enable")
|
|
577
|
+
Magick[:my_feature].enable_for_user(42) # => version 2 (action: "enable_for_user")
|
|
578
|
+
|
|
579
|
+
# Inspect history (hot window: last 50 versions by default)
|
|
580
|
+
Magick.versioning.get_versions(:my_feature)
|
|
581
|
+
|
|
582
|
+
# Include the unlimited ActiveRecord archive (when AR adapter is configured)
|
|
583
|
+
Magick.versioning.get_versions(:my_feature, all: true)
|
|
584
|
+
|
|
585
|
+
# Rollback fully restores a snapshot: value (including false/empty), status,
|
|
586
|
+
# group, and the entire targeting hash — and records the rollback itself as a
|
|
587
|
+
# new version, so history only ever rolls forward.
|
|
588
|
+
Magick.versioning.rollback(:my_feature, 2)
|
|
589
|
+
|
|
590
|
+
# Manual snapshots still work (action: "manual")
|
|
536
591
|
Magick.versioning.save_version(:my_feature, created_by: current_user.id)
|
|
592
|
+
```
|
|
537
593
|
|
|
538
|
-
|
|
539
|
-
|
|
594
|
+
**Retention is tiered:** memory/Redis keep the last `max_versions` snapshots
|
|
595
|
+
(default 50) for fast access; the ActiveRecord adapter keeps an unlimited
|
|
596
|
+
archive that also survives feature deletion.
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
Magick.configure do
|
|
600
|
+
versioning enabled: true, max_versions: 50
|
|
601
|
+
end
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Attribution:** wrap changes in `Magick.with_actor` to stamp audit entries
|
|
605
|
+
(`user_id`) and versions (`created_by`):
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
Magick.with_actor(current_user.id) do
|
|
609
|
+
Magick[:my_feature].enable_for_user(42)
|
|
610
|
+
end
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
**Boot replay is not recorded:** the Rails railtie loads `config/features.rb`
|
|
614
|
+
inside `Magick.definition_mode`, so re-applying declarative definitions on
|
|
615
|
+
every process boot does not flood history. Non-Rails apps should wrap their
|
|
616
|
+
own definition file load the same way:
|
|
617
|
+
|
|
618
|
+
```ruby
|
|
619
|
+
Magick.definition_mode { load 'config/features.rb' }
|
|
540
620
|
```
|
|
541
621
|
|
|
542
622
|
#### Performance Metrics
|
|
@@ -595,6 +675,11 @@ end
|
|
|
595
675
|
|
|
596
676
|
#### Audit Logging
|
|
597
677
|
|
|
678
|
+
Every mutation is logged under its real action name (`enable`, `disable`,
|
|
679
|
+
`set_value`, `enable_for_user`, `exclude_role`, `set_status`, `set_group`,
|
|
680
|
+
`delete`, `rollback`, …). One logical operation produces exactly one entry:
|
|
681
|
+
`enable` no longer surfaces as a bare `set_value`.
|
|
682
|
+
|
|
598
683
|
```ruby
|
|
599
684
|
# View audit log entries
|
|
600
685
|
entries = Magick.audit_log.entries(feature_name: :my_feature, limit: 100)
|
|
@@ -603,6 +688,15 @@ entries.each do |entry|
|
|
|
603
688
|
end
|
|
604
689
|
```
|
|
605
690
|
|
|
691
|
+
In the Admin UI, configure a `current_actor` hook so every change made
|
|
692
|
+
through the UI is attributed:
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
Magick::AdminUI.configure do |config|
|
|
696
|
+
config.current_actor = ->(controller) { controller.session[:admin_id] }
|
|
697
|
+
end
|
|
698
|
+
```
|
|
699
|
+
|
|
606
700
|
## Architecture
|
|
607
701
|
|
|
608
702
|
### Adapters
|
|
@@ -15,6 +15,9 @@ module Magick
|
|
|
15
15
|
layout 'application'
|
|
16
16
|
before_action :authenticate_admin!
|
|
17
17
|
before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting update_variants]
|
|
18
|
+
# Attribute every change made during the request to the configured
|
|
19
|
+
# actor, so audit entries and version snapshots record who did it.
|
|
20
|
+
around_action :with_magick_actor
|
|
18
21
|
# Render the TRUE current state, not this process's local cache. In a
|
|
19
22
|
# multi-process / multi-container deployment the enable/disable POST and
|
|
20
23
|
# the redirected GET are load-balanced to different processes, so the
|
|
@@ -39,8 +42,7 @@ module Magick
|
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
def partially_enabled?(feature)
|
|
42
|
-
|
|
43
|
-
targeting.any? && !targeting.empty?
|
|
45
|
+
(feature.targeting || {}).any?
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
def index
|
|
@@ -129,7 +131,6 @@ module Magick
|
|
|
129
131
|
end
|
|
130
132
|
|
|
131
133
|
def update_targeting
|
|
132
|
-
# Handle targeting updates from form
|
|
133
134
|
targeting_params = params[:targeting] || {}
|
|
134
135
|
unless hash_like?(targeting_params)
|
|
135
136
|
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid targeting payload.'
|
|
@@ -140,146 +141,13 @@ module Magick
|
|
|
140
141
|
feature_name = @feature.name.to_s
|
|
141
142
|
@feature = Magick.features[feature_name] if Magick.features.key?(feature_name)
|
|
142
143
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# Rails checkboxes don't send unchecked values, so we need to check what was sent
|
|
147
|
-
current_roles = current_targeting[:role].is_a?(Array) ? current_targeting[:role] : (current_targeting[:role] ? [current_targeting[:role]] : [])
|
|
148
|
-
selected_roles = Array(targeting_params[:roles]).reject(&:blank?)
|
|
149
|
-
|
|
150
|
-
# Disable roles that are no longer selected
|
|
151
|
-
(current_roles - selected_roles).each do |role|
|
|
152
|
-
@feature.disable_for_role(role) if role.present?
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# Enable newly selected roles
|
|
156
|
-
(selected_roles - current_roles).each do |role|
|
|
157
|
-
@feature.enable_for_role(role) if role.present?
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Handle tags - always clear existing and set new ones
|
|
161
|
-
# Rails checkboxes don't send unchecked values, so we need to check what was sent
|
|
162
|
-
current_tags = current_targeting[:tag].is_a?(Array) ? current_targeting[:tag] : (current_targeting[:tag] ? [current_targeting[:tag]] : [])
|
|
163
|
-
selected_tags = Array(targeting_params[:tags]).reject(&:blank?)
|
|
164
|
-
|
|
165
|
-
# Disable tags that are no longer selected
|
|
166
|
-
(current_tags - selected_tags).each do |tag|
|
|
167
|
-
@feature.disable_for_tag(tag) if tag.present?
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# Enable newly selected tags
|
|
171
|
-
(selected_tags - current_tags).each do |tag|
|
|
172
|
-
@feature.enable_for_tag(tag) if tag.present?
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# Handle user IDs - replace existing user targeting
|
|
176
|
-
if targeting_params[:user_ids].present?
|
|
177
|
-
user_ids = targeting_params[:user_ids].split(',').map(&:strip).reject(&:blank?)
|
|
178
|
-
current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
|
|
179
|
-
|
|
180
|
-
# Disable users that are no longer in the list
|
|
181
|
-
(current_user_ids - user_ids).each do |user_id|
|
|
182
|
-
@feature.disable_for_user(user_id) if user_id.present?
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Enable new users
|
|
186
|
-
(user_ids - current_user_ids).each do |user_id|
|
|
187
|
-
@feature.enable_for_user(user_id) if user_id.present?
|
|
188
|
-
end
|
|
189
|
-
elsif targeting_params.key?(:user_ids) && targeting_params[:user_ids].blank?
|
|
190
|
-
# Clear all user targeting if field was cleared
|
|
191
|
-
current_user_ids = current_targeting[:user].is_a?(Array) ? current_targeting[:user] : (current_targeting[:user] ? [current_targeting[:user]] : [])
|
|
192
|
-
current_user_ids.each do |user_id|
|
|
193
|
-
@feature.disable_for_user(user_id) if user_id.present?
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# Handle percentage of users
|
|
198
|
-
percentage_users_value = targeting_params[:percentage_users]
|
|
199
|
-
if percentage_users_value.present? && percentage_users_value.to_s.strip != ''
|
|
200
|
-
percentage = percentage_users_value.to_f
|
|
201
|
-
if percentage > 0 && percentage <= 100
|
|
202
|
-
result = @feature.enable_percentage_of_users(percentage)
|
|
203
|
-
Rails.logger.debug "Magick: Enabled percentage_users #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
|
|
204
|
-
else
|
|
205
|
-
# Value is 0 or invalid - disable
|
|
206
|
-
@feature.disable_percentage_of_users
|
|
207
|
-
end
|
|
208
|
-
else
|
|
209
|
-
# Field is empty - disable if it was previously set
|
|
210
|
-
@feature.disable_percentage_of_users if current_targeting[:percentage_users]
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
# Handle percentage of requests
|
|
214
|
-
percentage_requests_value = targeting_params[:percentage_requests]
|
|
215
|
-
if percentage_requests_value.present? && percentage_requests_value.to_s.strip != ''
|
|
216
|
-
percentage = percentage_requests_value.to_f
|
|
217
|
-
if percentage > 0 && percentage <= 100
|
|
218
|
-
result = @feature.enable_percentage_of_requests(percentage)
|
|
219
|
-
Rails.logger.debug "Magick: Enabled percentage_requests #{percentage} for #{@feature.name}: #{result}" if defined?(Rails)
|
|
220
|
-
else
|
|
221
|
-
# Value is 0 or invalid - disable
|
|
222
|
-
@feature.disable_percentage_of_requests
|
|
223
|
-
end
|
|
224
|
-
else
|
|
225
|
-
# Field is empty - disable if it was previously set
|
|
226
|
-
@feature.disable_percentage_of_requests if current_targeting[:percentage_requests]
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
# Handle excluded user IDs
|
|
230
|
-
if targeting_params[:excluded_user_ids].present?
|
|
231
|
-
excluded_user_ids = targeting_params[:excluded_user_ids].split(',').map(&:strip).reject(&:blank?)
|
|
232
|
-
current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
|
|
233
|
-
|
|
234
|
-
(current_excluded_users - excluded_user_ids).each do |user_id|
|
|
235
|
-
@feature.remove_user_exclusion(user_id) if user_id.present?
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
(excluded_user_ids - current_excluded_users).each do |user_id|
|
|
239
|
-
@feature.exclude_user(user_id) if user_id.present?
|
|
240
|
-
end
|
|
241
|
-
elsif targeting_params.key?(:excluded_user_ids) && targeting_params[:excluded_user_ids].blank?
|
|
242
|
-
current_excluded_users = current_targeting[:excluded_users].is_a?(Array) ? current_targeting[:excluded_users] : (current_targeting[:excluded_users] ? [current_targeting[:excluded_users]] : [])
|
|
243
|
-
current_excluded_users.each do |user_id|
|
|
244
|
-
@feature.remove_user_exclusion(user_id) if user_id.present?
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
# Handle excluded roles
|
|
249
|
-
current_excluded_roles = current_targeting[:excluded_roles].is_a?(Array) ? current_targeting[:excluded_roles] : (current_targeting[:excluded_roles] ? [current_targeting[:excluded_roles]] : [])
|
|
250
|
-
selected_excluded_roles = Array(targeting_params[:excluded_roles]).reject(&:blank?)
|
|
251
|
-
|
|
252
|
-
(current_excluded_roles - selected_excluded_roles).each do |role|
|
|
253
|
-
@feature.remove_role_exclusion(role) if role.present?
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
(selected_excluded_roles - current_excluded_roles).each do |role|
|
|
257
|
-
@feature.exclude_role(role) if role.present?
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
# Handle excluded tags
|
|
261
|
-
current_excluded_tags = current_targeting[:excluded_tags].is_a?(Array) ? current_targeting[:excluded_tags] : (current_targeting[:excluded_tags] ? [current_targeting[:excluded_tags]] : [])
|
|
262
|
-
selected_excluded_tags = Array(targeting_params[:excluded_tags]).reject(&:blank?)
|
|
263
|
-
|
|
264
|
-
(current_excluded_tags - selected_excluded_tags).each do |tag|
|
|
265
|
-
@feature.remove_tag_exclusion(tag) if tag.present?
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
(selected_excluded_tags - current_excluded_tags).each do |tag|
|
|
269
|
-
@feature.exclude_tag(tag) if tag.present?
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# After all targeting updates, ensure we're using the registered feature instance
|
|
273
|
-
# and reload it to get the latest state from adapter
|
|
274
|
-
feature_name = @feature.name.to_s
|
|
275
|
-
if Magick.features.key?(feature_name)
|
|
276
|
-
@feature = Magick.features[feature_name]
|
|
277
|
-
@feature.reload
|
|
278
|
-
else
|
|
279
|
-
@feature.reload
|
|
280
|
-
end
|
|
144
|
+
# One declarative write: one audit entry + one version per submit.
|
|
145
|
+
@feature.replace_targeting(desired_targeting_from_form(targeting_params))
|
|
146
|
+
@feature.reload
|
|
281
147
|
|
|
282
148
|
redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Targeting updated successfully'
|
|
149
|
+
rescue Magick::InvalidTargetingError => e
|
|
150
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Invalid targeting: #{e.message}"
|
|
283
151
|
rescue StandardError => e
|
|
284
152
|
Rails.logger.error "Magick: Error updating targeting for #{@feature.name}: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
|
|
285
153
|
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update targeting — see server logs for details.'
|
|
@@ -319,8 +187,72 @@ module Magick
|
|
|
319
187
|
redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update variants — see server logs for details.'
|
|
320
188
|
end
|
|
321
189
|
|
|
190
|
+
# Targeting rules the edit form has no fields for. They are carried over
|
|
191
|
+
# from the current state on every submit so a form save can never
|
|
192
|
+
# silently destroy them. (:variants is preserved by replace_targeting
|
|
193
|
+
# itself.)
|
|
194
|
+
FORM_UNMANAGED_TARGETING_KEYS = %i[
|
|
195
|
+
group excluded_groups ip_address excluded_ip_addresses
|
|
196
|
+
date_range custom_attributes complex_conditions
|
|
197
|
+
].freeze
|
|
198
|
+
|
|
322
199
|
private
|
|
323
200
|
|
|
201
|
+
# Build the full desired targeting state from the edit form. Checkbox
|
|
202
|
+
# groups (roles/tags and their exclusions) and the percentage fields are
|
|
203
|
+
# authoritative on every submit — unchecked/blank means "remove the
|
|
204
|
+
# rule". The comma-separated user lists are only authoritative when
|
|
205
|
+
# their field was actually sent. Percentages stay lenient here (blank
|
|
206
|
+
# or out-of-range clears the rule, as the form always behaved) —
|
|
207
|
+
# strict validation is for API callers of replace_targeting.
|
|
208
|
+
def desired_targeting_from_form(targeting_params)
|
|
209
|
+
current = @feature.targeting || {}
|
|
210
|
+
desired = {}
|
|
211
|
+
FORM_UNMANAGED_TARGETING_KEYS.each { |key| desired[key] = current[key] if current.key?(key) }
|
|
212
|
+
|
|
213
|
+
desired[:role] = Array(targeting_params[:roles]).reject(&:blank?)
|
|
214
|
+
desired[:tag] = Array(targeting_params[:tags]).reject(&:blank?)
|
|
215
|
+
desired[:excluded_roles] = Array(targeting_params[:excluded_roles]).reject(&:blank?)
|
|
216
|
+
desired[:excluded_tags] = Array(targeting_params[:excluded_tags]).reject(&:blank?)
|
|
217
|
+
|
|
218
|
+
desired[:user] = csv_ids(targeting_params, :user_ids, current[:user])
|
|
219
|
+
desired[:excluded_users] = csv_ids(targeting_params, :excluded_user_ids, current[:excluded_users])
|
|
220
|
+
|
|
221
|
+
desired[:percentage_users] = form_percentage(targeting_params[:percentage_users])
|
|
222
|
+
desired[:percentage_requests] = form_percentage(targeting_params[:percentage_requests])
|
|
223
|
+
|
|
224
|
+
desired.compact
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def csv_ids(targeting_params, field, current_value)
|
|
228
|
+
return Array(current_value) unless targeting_params.key?(field)
|
|
229
|
+
|
|
230
|
+
targeting_params[field].to_s.split(',').map(&:strip).reject(&:blank?)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def form_percentage(raw)
|
|
234
|
+
return nil if raw.blank?
|
|
235
|
+
|
|
236
|
+
percentage = raw.to_f
|
|
237
|
+
percentage.positive? && percentage <= 100 ? percentage : nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Resolve the acting admin via the configurable AdminUI hook and run the
|
|
241
|
+
# action inside Magick.with_actor. A failing resolver only costs
|
|
242
|
+
# attribution — it must never 500 the admin UI, and it is rescued
|
|
243
|
+
# separately so an action error is never swallowed or re-run.
|
|
244
|
+
def with_magick_actor(&block)
|
|
245
|
+
actor = begin
|
|
246
|
+
resolver = Magick::AdminUI.config.current_actor
|
|
247
|
+
resolver.respond_to?(:call) ? resolver.call(self) : nil
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
Rails.logger.warn "Magick: current_actor hook failed: #{e.class}: #{e.message}" if defined?(Rails)
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
actor ? Magick.with_actor(actor, &block) : yield
|
|
254
|
+
end
|
|
255
|
+
|
|
324
256
|
def authenticate_admin!
|
|
325
257
|
return unless Magick::AdminUI.config.require_role
|
|
326
258
|
|
data/config/magick.rb.example
CHANGED
|
@@ -20,11 +20,13 @@ Magick.configure do
|
|
|
20
20
|
# Enable performance metrics tracking
|
|
21
21
|
performance_metrics enabled: true
|
|
22
22
|
|
|
23
|
-
# Enable audit logging
|
|
23
|
+
# Enable audit logging (every mutation is logged under its real action name)
|
|
24
24
|
audit_log enabled: true
|
|
25
25
|
|
|
26
|
-
# Enable versioning
|
|
27
|
-
|
|
26
|
+
# Enable versioning (every save creates a version snapshot; allows rollback)
|
|
27
|
+
# max_versions caps the hot window kept in memory/Redis; the ActiveRecord
|
|
28
|
+
# adapter keeps an unlimited archive.
|
|
29
|
+
versioning enabled: true, max_versions: 50
|
|
28
30
|
|
|
29
31
|
# Enable deprecation warnings
|
|
30
32
|
warn_on_deprecated true
|
|
@@ -23,11 +23,13 @@ Magick.configure do
|
|
|
23
23
|
# Enable performance metrics tracking
|
|
24
24
|
performance_metrics enabled: true
|
|
25
25
|
|
|
26
|
-
# Enable audit logging (
|
|
26
|
+
# Enable audit logging (every mutation is logged under its real action name)
|
|
27
27
|
audit_log enabled: true
|
|
28
28
|
|
|
29
|
-
# Enable versioning
|
|
30
|
-
|
|
29
|
+
# Enable versioning (every save creates a version snapshot; allows rollback)
|
|
30
|
+
# max_versions caps the hot window kept in memory/Redis; the ActiveRecord
|
|
31
|
+
# adapter keeps an unlimited archive.
|
|
32
|
+
versioning enabled: true, max_versions: 50
|
|
31
33
|
|
|
32
34
|
# Enable deprecation warnings in logs
|
|
33
35
|
warn_on_deprecated enabled: true
|
|
@@ -176,7 +176,9 @@ module Magick
|
|
|
176
176
|
features += memory_adapter.all_features if memory_adapter
|
|
177
177
|
features += redis_adapter.all_features if redis_adapter
|
|
178
178
|
features += active_record_adapter.all_features if active_record_adapter
|
|
179
|
-
|
|
179
|
+
# Version history is stored under a reserved pseudo-feature namespace;
|
|
180
|
+
# it is bookkeeping, not a feature.
|
|
181
|
+
features.uniq.reject { |f| f.to_s.start_with?(Versioning::STORE_PREFIX) }
|
|
180
182
|
end
|
|
181
183
|
|
|
182
184
|
# Load all keys for a single feature in one call instead of N separate get() calls
|
|
@@ -357,9 +359,13 @@ module Magick
|
|
|
357
359
|
end
|
|
358
360
|
end
|
|
359
361
|
|
|
362
|
+
# Public so Versioning can apply tiered retention: hot window written to
|
|
363
|
+
# memory/Redis, unlimited archive written to ActiveRecord only.
|
|
364
|
+
attr_reader :memory_adapter, :redis_adapter, :active_record_adapter
|
|
365
|
+
|
|
360
366
|
private
|
|
361
367
|
|
|
362
|
-
attr_reader :
|
|
368
|
+
attr_reader :circuit_breaker
|
|
363
369
|
|
|
364
370
|
# Signal the subscribe loop to return, then close the connection so any
|
|
365
371
|
# retry/reconnect attempt fails fast instead of sleeping for 5s.
|
data/lib/magick/admin_ui.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Magick
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
class Configuration
|
|
20
|
-
attr_accessor :theme, :brand_name, :require_role, :available_roles, :available_tags
|
|
20
|
+
attr_accessor :theme, :brand_name, :require_role, :available_roles, :available_tags, :current_actor
|
|
21
21
|
|
|
22
22
|
def initialize
|
|
23
23
|
@theme = :light
|
|
@@ -25,6 +25,10 @@ module Magick
|
|
|
25
25
|
@require_role = nil
|
|
26
26
|
@available_roles = [] # Can be populated via DSL: admin_ui { roles ['admin', 'user', 'manager'] }
|
|
27
27
|
@available_tags = nil # Can be array or lambda: -> { Tag.all }
|
|
28
|
+
# Lambda receiving the controller, returning who is making the
|
|
29
|
+
# change; stamped onto audit entries (user_id) and versions
|
|
30
|
+
# (created_by): -> (controller) { controller.current_user&.id }
|
|
31
|
+
@current_actor = nil
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
# Get available tags, calling lambda if needed
|
data/lib/magick/config.rb
CHANGED
|
@@ -135,8 +135,11 @@ module Magick
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
def versioning(enabled: true)
|
|
139
|
-
@
|
|
138
|
+
def versioning(enabled: true, max_versions: Versioning::DEFAULT_MAX_VERSIONS)
|
|
139
|
+
@versioning_enabled = enabled
|
|
140
|
+
@versioning = if enabled
|
|
141
|
+
Versioning.new(adapter_registry || default_adapter_registry, max_versions: max_versions)
|
|
142
|
+
end
|
|
140
143
|
end
|
|
141
144
|
|
|
142
145
|
def circuit_breaker(threshold: nil, timeout: nil)
|
|
@@ -186,8 +189,11 @@ module Magick
|
|
|
186
189
|
end
|
|
187
190
|
end
|
|
188
191
|
|
|
189
|
-
|
|
190
|
-
|
|
192
|
+
# Read the ivars directly: calling the DSL methods here would re-run
|
|
193
|
+
# them with their defaults and stomp explicit `enabled: false` settings.
|
|
194
|
+
Magick.audit_log = @audit_log if @audit_log
|
|
195
|
+
Magick.versioning = @versioning if @versioning
|
|
196
|
+
Magick.versioning_enabled = @versioning_enabled unless @versioning_enabled.nil?
|
|
191
197
|
Magick.warn_on_deprecated = warn_on_deprecated
|
|
192
198
|
end
|
|
193
199
|
|
data/lib/magick/errors.rb
CHANGED
|
@@ -5,5 +5,9 @@ module Magick
|
|
|
5
5
|
class FeatureNotFoundError < Error; end
|
|
6
6
|
class InvalidFeatureTypeError < Error; end
|
|
7
7
|
class InvalidFeatureValueError < Error; end
|
|
8
|
+
# Raised by Feature#replace_targeting when a wire targeting payload
|
|
9
|
+
# contains unknown keys or invalid values. Nothing is applied on failure,
|
|
10
|
+
# so API callers can map it straight to a 422.
|
|
11
|
+
class InvalidTargetingError < Error; end
|
|
8
12
|
class AdapterError < Error; end
|
|
9
13
|
end
|
data/lib/magick/export_import.rb
CHANGED
|
@@ -103,55 +103,18 @@ module Magick
|
|
|
103
103
|
feature.set_value(value) if !value.nil? && !(value.is_a?(String) && value.empty?)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# rubocop:disable Metrics/MethodLength
|
|
107
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
|
108
106
|
def self.apply_targeting(feature, targeting)
|
|
109
|
-
targeting.
|
|
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
|
|
107
|
+
return unless targeting.is_a?(Hash)
|
|
145
108
|
|
|
146
|
-
|
|
147
|
-
|
|
109
|
+
# Exports from gem versions where variants leaked into the targeting
|
|
110
|
+
# hash must stay importable; variants are applied separately from the
|
|
111
|
+
# top-level key.
|
|
112
|
+
payload = targeting.reject { |key, _| key.to_s == 'variants' }
|
|
113
|
+
return if payload.empty?
|
|
148
114
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
feature.enable_for_custom_attribute(attr, rule_h[:values], operator: (rule_h[:operator] || :equals).to_sym)
|
|
154
|
-
end
|
|
115
|
+
feature.replace_targeting(payload)
|
|
116
|
+
rescue Magick::InvalidTargetingError => e
|
|
117
|
+
raise ImportError, "Magick.import: invalid targeting for '#{feature.name}': #{e.message}"
|
|
155
118
|
end
|
|
156
119
|
|
|
157
120
|
def self.apply_variants(feature, variants)
|