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/rails/railtie.rb
CHANGED
|
@@ -87,15 +87,18 @@ if defined?(Rails)
|
|
|
87
87
|
# Supports both config/features.rb and config/initializers/features.rb
|
|
88
88
|
config.after_initialize do
|
|
89
89
|
# Try config/features.rb first (recommended location)
|
|
90
|
+
# definition_mode suppresses audit/version recording: boot replays
|
|
91
|
+
# the declarative definitions in every container, and recording
|
|
92
|
+
# those would flood history with identical snapshots.
|
|
90
93
|
features_file = Rails.root.join('config', 'features.rb')
|
|
91
94
|
if File.exist?(features_file)
|
|
92
|
-
load features_file
|
|
95
|
+
Magick.definition_mode { load features_file }
|
|
93
96
|
else
|
|
94
97
|
# Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
|
|
95
98
|
initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
|
|
96
99
|
if File.exist?(initializer_file) && !defined?(Magick::Rails::FeaturesLoaded)
|
|
97
100
|
# Only load if not already loaded (Rails may have already loaded it)
|
|
98
|
-
load initializer_file
|
|
101
|
+
Magick.definition_mode { load initializer_file }
|
|
99
102
|
end
|
|
100
103
|
end
|
|
101
104
|
begin
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Magick
|
|
7
|
+
# Translates between the internal targeting hash and the wire-format
|
|
8
|
+
# targeting payload exchanged with control planes (panel contract):
|
|
9
|
+
#
|
|
10
|
+
# GET -> Feature#as_json emits `serialize(targeting)` — string keys,
|
|
11
|
+
# arrays of strings, floats; the "targeting" key is always
|
|
12
|
+
# present ({} = no targeting).
|
|
13
|
+
# PATCH -> Feature#replace_targeting runs the inbound payload through
|
|
14
|
+
# `normalize` — lenient about aliases/scalars/key types, strict
|
|
15
|
+
# about vocabulary and values (all-or-nothing).
|
|
16
|
+
#
|
|
17
|
+
# The internal-only :variants key never crosses the wire in either
|
|
18
|
+
# direction: `serialize` drops it, `normalize` rejects it.
|
|
19
|
+
module TargetingPayload
|
|
20
|
+
# Inclusion keys are singular, exclusion keys plural — mirrors the
|
|
21
|
+
# internal @targeting layout, which is also the persisted layout.
|
|
22
|
+
ARRAY_KEYS = %i[
|
|
23
|
+
user group role tag ip_address
|
|
24
|
+
excluded_users excluded_groups excluded_roles excluded_tags excluded_ip_addresses
|
|
25
|
+
].freeze
|
|
26
|
+
PERCENTAGE_KEYS = %i[percentage_users percentage_requests].freeze
|
|
27
|
+
STRUCTURED_KEYS = %i[date_range custom_attributes complex_conditions].freeze
|
|
28
|
+
CANONICAL_KEYS = (ARRAY_KEYS + PERCENTAGE_KEYS + STRUCTURED_KEYS).freeze
|
|
29
|
+
|
|
30
|
+
# Plural spellings accepted on input for parity with Magick.import.
|
|
31
|
+
ALIASES = {
|
|
32
|
+
users: :user,
|
|
33
|
+
groups: :group,
|
|
34
|
+
roles: :role,
|
|
35
|
+
tags: :tag,
|
|
36
|
+
ip_addresses: :ip_address
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
IP_KEYS = %i[ip_address excluded_ip_addresses].freeze
|
|
40
|
+
|
|
41
|
+
CUSTOM_ATTRIBUTE_OPERATORS = %i[equals eq not_equals ne in not_in greater_than gt less_than lt].freeze
|
|
42
|
+
COMPLEX_OPERATORS = %i[and or].freeze
|
|
43
|
+
COMPLEX_CONDITION_TYPES = %i[user group role custom_attribute].freeze
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Wire payload -> canonical internal targeting hash. Raises
|
|
48
|
+
# InvalidTargetingError on the first problem, before any state is
|
|
49
|
+
# touched, so callers get all-or-nothing semantics for free.
|
|
50
|
+
def normalize(payload)
|
|
51
|
+
raise InvalidTargetingError, "targeting must be a Hash, got #{payload.class}" unless payload.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
normalized = {}
|
|
54
|
+
payload.each do |raw_key, raw_value|
|
|
55
|
+
key = canonical_key(raw_key)
|
|
56
|
+
raise InvalidTargetingError, "conflicting targeting keys resolve to '#{key}'" if normalized.key?(key)
|
|
57
|
+
next if raw_value.nil? # nil = remove the rule, same as omitting the key
|
|
58
|
+
|
|
59
|
+
value = normalize_value(key, raw_value)
|
|
60
|
+
normalized[key] = value unless value.nil?
|
|
61
|
+
end
|
|
62
|
+
normalized
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Internal targeting hash -> wire payload. Never raises: the read path
|
|
66
|
+
# must serialize whatever historical state an adapter hands back.
|
|
67
|
+
def serialize(targeting)
|
|
68
|
+
return {} unless targeting.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
targeting.each_with_object({}) do |(key, value), wire|
|
|
71
|
+
key = key.to_sym
|
|
72
|
+
next if key == :variants # internal-only, managed via set_variants
|
|
73
|
+
|
|
74
|
+
wire[key.to_s] =
|
|
75
|
+
if ARRAY_KEYS.include?(key)
|
|
76
|
+
Array(value).map(&:to_s)
|
|
77
|
+
elsif PERCENTAGE_KEYS.include?(key)
|
|
78
|
+
value.to_f
|
|
79
|
+
elsif key == :date_range
|
|
80
|
+
serialize_date_range(value)
|
|
81
|
+
else
|
|
82
|
+
deep_stringify(value)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def canonical_key(raw_key)
|
|
88
|
+
key = raw_key.to_sym
|
|
89
|
+
key = ALIASES.fetch(key, key)
|
|
90
|
+
if key == :variants
|
|
91
|
+
raise InvalidTargetingError, 'variants are not part of the targeting payload (use set_variants)'
|
|
92
|
+
end
|
|
93
|
+
raise InvalidTargetingError, "unknown targeting key: '#{raw_key}'" unless CANONICAL_KEYS.include?(key)
|
|
94
|
+
|
|
95
|
+
key
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_value(key, value)
|
|
99
|
+
if ARRAY_KEYS.include?(key)
|
|
100
|
+
normalize_array(key, value)
|
|
101
|
+
elsif PERCENTAGE_KEYS.include?(key)
|
|
102
|
+
normalize_percentage(key, value)
|
|
103
|
+
elsif key == :date_range
|
|
104
|
+
normalize_date_range(value)
|
|
105
|
+
elsif key == :custom_attributes
|
|
106
|
+
normalize_custom_attributes(value)
|
|
107
|
+
else # :complex_conditions
|
|
108
|
+
normalize_complex_conditions(value)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Scalars are accepted as one-element lists; empty lists mean "no rule"
|
|
113
|
+
# and collapse to key removal, keeping {} the only spelling of
|
|
114
|
+
# "no targeting".
|
|
115
|
+
def normalize_array(key, value)
|
|
116
|
+
values = (value.is_a?(Array) ? value : [value]).compact.map { |v| v.to_s.strip }.reject(&:empty?).uniq
|
|
117
|
+
return nil if values.empty?
|
|
118
|
+
|
|
119
|
+
validate_ip_list!(key, values) if IP_KEYS.include?(key)
|
|
120
|
+
values
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_ip_list!(key, values)
|
|
124
|
+
values.each do |ip|
|
|
125
|
+
IPAddr.new(ip)
|
|
126
|
+
rescue ArgumentError # includes IPAddr::Error
|
|
127
|
+
raise InvalidTargetingError, "invalid IP address or CIDR range in '#{key}': '#{ip}'"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_percentage(key, value)
|
|
132
|
+
percentage = begin
|
|
133
|
+
Float(value)
|
|
134
|
+
rescue ArgumentError, TypeError
|
|
135
|
+
raise InvalidTargetingError, "'#{key}' must be a number, got #{value.inspect}"
|
|
136
|
+
end
|
|
137
|
+
unless percentage.positive? && percentage <= 100
|
|
138
|
+
raise InvalidTargetingError, "'#{key}' must be within (0, 100], got #{percentage}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
percentage
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def normalize_date_range(value)
|
|
145
|
+
raise InvalidTargetingError, "'date_range' must be a Hash with start and end" unless value.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
start_value = value[:start] || value['start']
|
|
148
|
+
end_value = value[:end] || value['end']
|
|
149
|
+
raise InvalidTargetingError, "'date_range' requires both start and end" unless start_value && end_value
|
|
150
|
+
|
|
151
|
+
{ start: parse_date_bound(start_value), end: parse_date_bound(end_value) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_date_bound(value)
|
|
155
|
+
return value unless value.is_a?(String)
|
|
156
|
+
|
|
157
|
+
Time.parse(value)
|
|
158
|
+
value
|
|
159
|
+
rescue ArgumentError
|
|
160
|
+
raise InvalidTargetingError, "'date_range' bound is not a parseable time: '#{value}'"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def normalize_custom_attributes(value)
|
|
164
|
+
raise InvalidTargetingError, "'custom_attributes' must be a Hash" unless value.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
value.each_with_object({}) do |(attribute, rule), result|
|
|
167
|
+
raise InvalidTargetingError, "rule for custom attribute '#{attribute}' must be a Hash" unless rule.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
values = Array(rule[:values] || rule['values']).compact.map(&:to_s).reject(&:empty?)
|
|
170
|
+
raise InvalidTargetingError, "custom attribute '#{attribute}' requires values" if values.empty?
|
|
171
|
+
|
|
172
|
+
operator = (rule[:operator] || rule['operator'] || :equals).to_sym
|
|
173
|
+
unless CUSTOM_ATTRIBUTE_OPERATORS.include?(operator)
|
|
174
|
+
raise InvalidTargetingError, "unknown operator '#{operator}' for custom attribute '#{attribute}'"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
result[attribute.to_sym] = { values: values, operator: operator }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def normalize_complex_conditions(value)
|
|
182
|
+
raise InvalidTargetingError, "'complex_conditions' must be a Hash" unless value.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
operator = (value[:operator] || value['operator'] || :and).to_sym
|
|
185
|
+
unless COMPLEX_OPERATORS.include?(operator)
|
|
186
|
+
raise InvalidTargetingError, "'complex_conditions' operator must be and/or, got '#{operator}'"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
conditions = value[:conditions] || value['conditions']
|
|
190
|
+
raise InvalidTargetingError, "'complex_conditions' requires a conditions Array" unless conditions.is_a?(Array)
|
|
191
|
+
|
|
192
|
+
{ operator: operator, conditions: conditions.map { |c| normalize_complex_condition(c) } }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def normalize_complex_condition(condition)
|
|
196
|
+
raise InvalidTargetingError, 'each complex condition must be a Hash' unless condition.is_a?(Hash)
|
|
197
|
+
|
|
198
|
+
type = (condition[:type] || condition['type']).to_s.to_sym
|
|
199
|
+
unless COMPLEX_CONDITION_TYPES.include?(type)
|
|
200
|
+
raise InvalidTargetingError, "unknown complex condition type: '#{type}'"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
params = condition[:params] || condition['params'] || {}
|
|
204
|
+
raise InvalidTargetingError, "params for complex condition '#{type}' must be a Hash" unless params.is_a?(Hash)
|
|
205
|
+
|
|
206
|
+
{ type: type, params: params.transform_keys(&:to_sym) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def serialize_date_range(value)
|
|
210
|
+
return deep_stringify(value) unless value.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
start_value = value[:start] || value['start']
|
|
213
|
+
end_value = value[:end] || value['end']
|
|
214
|
+
{ 'start' => serialize_time(start_value), 'end' => serialize_time(end_value) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def serialize_time(value)
|
|
218
|
+
return value if value.nil? || value.is_a?(String)
|
|
219
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
220
|
+
|
|
221
|
+
value.to_s
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def deep_stringify(value)
|
|
225
|
+
case value
|
|
226
|
+
when Hash
|
|
227
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
228
|
+
when Array
|
|
229
|
+
value.map { |v| deep_stringify(v) }
|
|
230
|
+
when Symbol
|
|
231
|
+
value.to_s
|
|
232
|
+
else
|
|
233
|
+
value
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
data/lib/magick/version.rb
CHANGED
data/lib/magick/versioning.rb
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
3
6
|
module Magick
|
|
4
7
|
class Versioning
|
|
8
|
+
DEFAULT_MAX_VERSIONS = 50
|
|
9
|
+
|
|
10
|
+
# Version history lives under a reserved pseudo-feature namespace so that
|
|
11
|
+
# feature reads (get_all_data) never drag snapshot blobs along, and so
|
|
12
|
+
# deleting a feature does not destroy its ActiveRecord archive row.
|
|
13
|
+
STORE_PREFIX = '__magick_versions:'
|
|
14
|
+
|
|
5
15
|
class Version
|
|
6
|
-
attr_reader :version, :feature_data, :timestamp, :created_by
|
|
16
|
+
attr_reader :version, :feature_data, :timestamp, :created_by, :action
|
|
7
17
|
|
|
8
|
-
def initialize(version, feature_data, created_by: nil)
|
|
18
|
+
def initialize(version, feature_data, created_by: nil, action: nil, timestamp: nil)
|
|
9
19
|
@version = version
|
|
10
20
|
@feature_data = feature_data
|
|
11
|
-
@timestamp = Time.now
|
|
21
|
+
@timestamp = timestamp || Time.now
|
|
12
22
|
@created_by = created_by
|
|
23
|
+
@action = action
|
|
13
24
|
end
|
|
14
25
|
|
|
15
26
|
def to_h
|
|
@@ -17,66 +28,65 @@ module Magick
|
|
|
17
28
|
version: version,
|
|
18
29
|
feature_data: feature_data,
|
|
19
30
|
timestamp: timestamp.iso8601,
|
|
20
|
-
created_by: created_by
|
|
31
|
+
created_by: created_by,
|
|
32
|
+
action: action
|
|
21
33
|
}
|
|
22
34
|
end
|
|
23
35
|
end
|
|
24
36
|
|
|
25
|
-
def initialize(adapter_registry)
|
|
37
|
+
def initialize(adapter_registry, max_versions: DEFAULT_MAX_VERSIONS)
|
|
26
38
|
@adapter_registry = adapter_registry
|
|
27
|
-
@
|
|
39
|
+
@max_versions = max_versions.to_i.positive? ? max_versions.to_i : DEFAULT_MAX_VERSIONS
|
|
40
|
+
# Process-local cache of the hot window, rehydrated lazily from the
|
|
41
|
+
# adapters so history survives restarts and is shared across containers.
|
|
42
|
+
@hot = {}
|
|
28
43
|
@mutex = Mutex.new
|
|
29
44
|
end
|
|
30
45
|
|
|
46
|
+
attr_reader :max_versions
|
|
47
|
+
|
|
48
|
+
# Explicit manual snapshot. Kept as public API from earlier releases;
|
|
49
|
+
# since 1.5.0 every Feature mutation also snapshots automatically.
|
|
31
50
|
def save_version(feature_name, version: nil, created_by: nil)
|
|
32
51
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
33
|
-
|
|
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
|
|
44
|
-
end
|
|
52
|
+
append(feature.name, snapshot_of(feature), version: version, created_by: created_by, action: 'manual')
|
|
53
|
+
end
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
# Called by Feature#record_change after every successful mutation.
|
|
56
|
+
# snapshot: allows callers (delete) to capture state before the mutation.
|
|
57
|
+
def record_change(feature, action: nil, created_by: nil, snapshot: nil)
|
|
58
|
+
data = snapshot ? deep_symbolize(JSON.parse(JSON.generate(snapshot))) : snapshot_of(feature)
|
|
59
|
+
append(feature.name, data, created_by: created_by, action: action)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Hot window (last max_versions, memory/Redis) by default; all: true
|
|
63
|
+
# merges the unlimited ActiveRecord archive when one is configured.
|
|
64
|
+
def get_versions(feature_name, all: false)
|
|
65
|
+
name = feature_name.to_s
|
|
66
|
+
hot = @mutex.synchronize { hot_window(name).dup }
|
|
67
|
+
return hot unless all
|
|
49
68
|
|
|
50
|
-
|
|
69
|
+
older = archive_versions(name).reject { |a| hot.any? { |h| h.version == a.version } }
|
|
70
|
+
(older + hot).sort_by(&:version)
|
|
51
71
|
end
|
|
52
72
|
|
|
73
|
+
# Restore the feature to the snapshot stored in the given version, then
|
|
74
|
+
# record the rollback itself as a new version + audit entry (history only
|
|
75
|
+
# ever rolls forward). Restores state wholesale: value (including false/
|
|
76
|
+
# empty), status, group, the entire targeting hash, and dependencies.
|
|
53
77
|
def rollback(feature_name, version)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return false unless target_version
|
|
78
|
+
entry = find_version(feature_name, version)
|
|
79
|
+
return false unless entry
|
|
57
80
|
|
|
58
81
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# Restore feature state
|
|
62
|
-
feature.set_value(feature_data[:value]) if feature_data[:value]
|
|
63
|
-
feature.set_status(feature_data[:status]) if feature_data[:status]
|
|
64
|
-
|
|
65
|
-
# Restore targeting
|
|
66
|
-
feature_data[:targeting]&.each do |type, values|
|
|
67
|
-
Array(values).each do |value|
|
|
68
|
-
case type.to_sym
|
|
69
|
-
when :user
|
|
70
|
-
feature.enable_for_user(value)
|
|
71
|
-
when :group
|
|
72
|
-
feature.enable_for_group(value)
|
|
73
|
-
when :role
|
|
74
|
-
feature.enable_for_role(value)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
82
|
+
Magick.suppress_change_recording do
|
|
83
|
+
feature.restore_snapshot!(entry.feature_data)
|
|
77
84
|
end
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
actor = Magick.current_actor
|
|
87
|
+
Magick.audit_log&.log(feature_name, 'rollback', user_id: actor, changes: { rolled_back_to: version })
|
|
88
|
+
record_change(feature, action: 'rollback', created_by: actor)
|
|
89
|
+
|
|
80
90
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
81
91
|
Magick::Rails::Events.rollback(feature_name, version: version)
|
|
82
92
|
end
|
|
@@ -84,17 +94,161 @@ module Magick
|
|
|
84
94
|
true
|
|
85
95
|
end
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Deep copy via JSON so stored snapshots never alias the feature's live
|
|
100
|
+
# targeting hash, and in-process entries match adapter-rehydrated ones.
|
|
101
|
+
def snapshot_of(feature)
|
|
102
|
+
deep_symbolize(JSON.parse(JSON.generate(feature.to_h)))
|
|
89
103
|
end
|
|
90
104
|
|
|
91
|
-
|
|
105
|
+
def append(name, snapshot, version: nil, created_by: nil, action: nil)
|
|
106
|
+
entry = @mutex.synchronize do
|
|
107
|
+
window = hot_window(name)
|
|
108
|
+
resolved = version || (window.last ? window.last.version + 1 : 1)
|
|
109
|
+
record = Version.new(resolved, snapshot, created_by: created_by, action: action)
|
|
110
|
+
window << record
|
|
111
|
+
window.shift while window.size > @max_versions
|
|
112
|
+
persist_hot_window(name, window)
|
|
113
|
+
persist_archive(name, record)
|
|
114
|
+
record
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
118
|
+
Magick::Rails::Events.version_saved(name, version: entry.version, created_by: created_by)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
entry
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Callers must hold @mutex.
|
|
125
|
+
def hot_window(name)
|
|
126
|
+
@hot[name] ||= load_hot_window(name)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def load_hot_window(name)
|
|
130
|
+
raw = read_hot_list(name)
|
|
131
|
+
raw = read_archive_tail(name) if raw.nil? || raw.empty?
|
|
132
|
+
Array(raw).filter_map { |h| rehydrate(h) }.sort_by(&:version).last(@max_versions)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_hot_list(name)
|
|
136
|
+
hot_adapters.each do |adapter|
|
|
137
|
+
list = safely { adapter.get(store_name(name), 'versions') }
|
|
138
|
+
list = parse_json(list) if list.is_a?(String)
|
|
139
|
+
return list if list.is_a?(Array) && !list.empty?
|
|
140
|
+
end
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Seed the hot window from the archive when memory/Redis are empty
|
|
145
|
+
# (fresh boot, Redis flush) so version numbering continues, not restarts.
|
|
146
|
+
def read_archive_tail(name)
|
|
147
|
+
adapter = archive_adapter
|
|
148
|
+
return nil unless adapter
|
|
149
|
+
|
|
150
|
+
data = safely { adapter.get_all_data(store_name(name)) }
|
|
151
|
+
return nil unless data.is_a?(Hash)
|
|
92
152
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
153
|
+
data.filter_map { |key, value| value if key.to_s.start_with?('version_') }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def archive_versions(name)
|
|
157
|
+
adapter = archive_adapter
|
|
158
|
+
return [] unless adapter
|
|
159
|
+
|
|
160
|
+
data = safely { adapter.get_all_data(store_name(name)) }
|
|
161
|
+
return [] unless data.is_a?(Hash)
|
|
162
|
+
|
|
163
|
+
data.filter_map { |key, value| rehydrate(value) if key.to_s.start_with?('version_') }.sort_by(&:version)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def persist_hot_window(name, window)
|
|
167
|
+
payload = window.map(&:to_h)
|
|
168
|
+
hot_adapters.each do |adapter|
|
|
169
|
+
safely { adapter.set(store_name(name), 'versions', payload) }
|
|
97
170
|
end
|
|
98
171
|
end
|
|
172
|
+
|
|
173
|
+
def persist_archive(name, entry)
|
|
174
|
+
adapter = archive_adapter
|
|
175
|
+
return unless adapter
|
|
176
|
+
|
|
177
|
+
safely { adapter.set(store_name(name), "version_#{entry.version}", entry.to_h) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def find_version(feature_name, version)
|
|
181
|
+
name = feature_name.to_s
|
|
182
|
+
hot = @mutex.synchronize { hot_window(name).dup }
|
|
183
|
+
found = hot.find { |v| v.version == version }
|
|
184
|
+
return found if found
|
|
185
|
+
|
|
186
|
+
adapter = archive_adapter
|
|
187
|
+
return nil unless adapter
|
|
188
|
+
|
|
189
|
+
raw = safely { adapter.get(store_name(name), "version_#{version}") }
|
|
190
|
+
raw ? rehydrate(raw) : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Hot window lives in memory + Redis (capped); the ActiveRecord adapter
|
|
194
|
+
# keeps the unlimited archive, one version_<n> key per entry.
|
|
195
|
+
def hot_adapters
|
|
196
|
+
registry = @adapter_registry
|
|
197
|
+
if registry.respond_to?(:memory_adapter)
|
|
198
|
+
[registry.memory_adapter, registry.redis_adapter].compact
|
|
199
|
+
else
|
|
200
|
+
[registry]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def archive_adapter
|
|
205
|
+
registry = @adapter_registry
|
|
206
|
+
registry.respond_to?(:active_record_adapter) ? registry.active_record_adapter : nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def store_name(name)
|
|
210
|
+
"#{STORE_PREFIX}#{name}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def rehydrate(raw)
|
|
214
|
+
raw = parse_json(raw) if raw.is_a?(String)
|
|
215
|
+
return nil unless raw.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
data = deep_symbolize(raw)
|
|
218
|
+
timestamp = data[:timestamp]
|
|
219
|
+
timestamp = safely { Time.parse(timestamp) } if timestamp.is_a?(String)
|
|
220
|
+
Version.new(
|
|
221
|
+
data[:version],
|
|
222
|
+
data[:feature_data] || {},
|
|
223
|
+
created_by: data[:created_by],
|
|
224
|
+
action: data[:action],
|
|
225
|
+
timestamp: timestamp
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def parse_json(str)
|
|
230
|
+
JSON.parse(str)
|
|
231
|
+
rescue JSON::ParserError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def deep_symbolize(obj)
|
|
236
|
+
case obj
|
|
237
|
+
when Hash
|
|
238
|
+
obj.each_with_object({}) { |(k, v), out| out[k.to_sym] = deep_symbolize(v) }
|
|
239
|
+
when Array
|
|
240
|
+
obj.map { |v| deep_symbolize(v) }
|
|
241
|
+
else
|
|
242
|
+
obj
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Version bookkeeping is best-effort: a flaky adapter must never turn a
|
|
247
|
+
# feature toggle into an exception.
|
|
248
|
+
def safely
|
|
249
|
+
yield
|
|
250
|
+
rescue StandardError
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
99
253
|
end
|
|
100
254
|
end
|
data/lib/magick.rb
CHANGED
|
@@ -23,6 +23,7 @@ require_relative 'magick/targeting/ip_address'
|
|
|
23
23
|
require_relative 'magick/targeting/custom_attribute'
|
|
24
24
|
require_relative 'magick/targeting/complex'
|
|
25
25
|
require_relative 'magick/errors'
|
|
26
|
+
require_relative 'magick/targeting_payload'
|
|
26
27
|
|
|
27
28
|
require_relative 'magick/log_safe'
|
|
28
29
|
require_relative 'magick/audit_log'
|
|
@@ -216,6 +217,64 @@ module Magick
|
|
|
216
217
|
@versioning ||= Versioning.new(adapter_registry || default_adapter_registry)
|
|
217
218
|
end
|
|
218
219
|
|
|
220
|
+
# When false, Feature#record_change skips version snapshots (audit log
|
|
221
|
+
# entries are still written). Set via `versioning enabled: false` in the
|
|
222
|
+
# configuration DSL.
|
|
223
|
+
attr_writer :versioning_enabled
|
|
224
|
+
|
|
225
|
+
def versioning_enabled?
|
|
226
|
+
@versioning_enabled.nil? || @versioning_enabled != false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Attribute all changes made inside the block to the given actor. Audit
|
|
230
|
+
# entries pick it up as user_id and versions as created_by, unless the
|
|
231
|
+
# call site passes an explicit user_id:.
|
|
232
|
+
#
|
|
233
|
+
# Magick.with_actor(current_user.id) { Magick[:checkout].enable }
|
|
234
|
+
def with_actor(actor)
|
|
235
|
+
previous = Thread.current[:magick_actor]
|
|
236
|
+
Thread.current[:magick_actor] = actor
|
|
237
|
+
yield
|
|
238
|
+
ensure
|
|
239
|
+
Thread.current[:magick_actor] = previous
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def current_actor
|
|
243
|
+
Thread.current[:magick_actor]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Suppress audit/version recording while declarative feature definitions
|
|
247
|
+
# are (re)applied. Process boot replays config/features.rb in every
|
|
248
|
+
# container; recording those replays would flood history with identical
|
|
249
|
+
# snapshots. The Rails railtie wraps the features file load in this;
|
|
250
|
+
# non-Rails apps should do the same around their definition file.
|
|
251
|
+
def definition_mode
|
|
252
|
+
previous = Thread.current[:magick_definition_mode]
|
|
253
|
+
Thread.current[:magick_definition_mode] = true
|
|
254
|
+
yield
|
|
255
|
+
ensure
|
|
256
|
+
Thread.current[:magick_definition_mode] = previous
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def definition_mode?
|
|
260
|
+
Thread.current[:magick_definition_mode] == true
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Reentrancy guard for Feature#record_change: the outermost public
|
|
264
|
+
# mutator records once; nested mutator calls (enable -> set_value) run
|
|
265
|
+
# silently so one logical operation never produces multiple entries.
|
|
266
|
+
def suppress_change_recording
|
|
267
|
+
previous = Thread.current[:magick_change_recording]
|
|
268
|
+
Thread.current[:magick_change_recording] = true
|
|
269
|
+
yield
|
|
270
|
+
ensure
|
|
271
|
+
Thread.current[:magick_change_recording] = previous
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def change_recording_suppressed?
|
|
275
|
+
Thread.current[:magick_change_recording] == true || definition_mode?
|
|
276
|
+
end
|
|
277
|
+
|
|
219
278
|
# Manually enable Redis tracking for performance metrics
|
|
220
279
|
# Useful if Redis adapter becomes available after initial configuration
|
|
221
280
|
def enable_redis_tracking(enable: true)
|
|
@@ -280,6 +339,9 @@ module Magick
|
|
|
280
339
|
@adapter_registry = nil
|
|
281
340
|
@default_adapter = nil
|
|
282
341
|
@default_adapter_registry = nil
|
|
342
|
+
@versioning = nil
|
|
343
|
+
@audit_log = nil
|
|
344
|
+
@versioning_enabled = nil
|
|
283
345
|
@performance_metrics&.clear!
|
|
284
346
|
end
|
|
285
347
|
|
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Lobanov
|
|
@@ -145,6 +145,7 @@ files:
|
|
|
145
145
|
- lib/magick/targeting/request_percentage.rb
|
|
146
146
|
- lib/magick/targeting/role.rb
|
|
147
147
|
- lib/magick/targeting/user.rb
|
|
148
|
+
- lib/magick/targeting_payload.rb
|
|
148
149
|
- lib/magick/testing_helpers.rb
|
|
149
150
|
- lib/magick/version.rb
|
|
150
151
|
- lib/magick/versioning.rb
|