star_gates 0.0.0 → 0.1.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/.dictate.toml +19 -0
- data/.pre-commit-config.yaml +20 -0
- data/CHANGELOG.md +52 -3
- data/LICENSE.txt +23 -16
- data/README.md +175 -16
- data/Rakefile +14 -4
- data/lib/star_gates/adapters/base_adapter.rb +65 -0
- data/lib/star_gates/adapters/memory_adapter.rb +93 -0
- data/lib/star_gates/adapters/redis_adapter.rb +119 -0
- data/lib/star_gates/application_star_gate.rb +398 -0
- data/lib/star_gates/circuit.rb +171 -0
- data/lib/star_gates/configuration.rb +172 -0
- data/lib/star_gates/dsl.rb +71 -2
- data/lib/star_gates/errors.rb +8 -0
- data/lib/star_gates/event_store/event_store.rb +79 -0
- data/lib/star_gates/event_store/memory_adapter.rb +78 -0
- data/lib/star_gates/events/base_event.rb +46 -0
- data/lib/star_gates/events/feature_events.rb +97 -0
- data/lib/star_gates/feature.rb +446 -0
- data/lib/star_gates/flags.rb +153 -0
- data/lib/star_gates/gate.rb +100 -0
- data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
- data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
- data/lib/star_gates/hexagonal/feature_service.rb +121 -0
- data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
- data/lib/star_gates/projections/feature_projection.rb +224 -0
- data/lib/star_gates/registry.rb +58 -0
- data/lib/star_gates/telemetry/opentelemetry_adapter.rb +254 -0
- data/lib/star_gates/telemetry/prometheus_adapter.rb +184 -0
- data/lib/star_gates/telemetry/telemetry_adapter.rb +174 -0
- data/lib/star_gates/version.rb +1 -1
- data/lib/star_gates.rb +308 -8
- metadata +95 -14
- data/sig/star_gates.rbs +0 -4
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'state_machines'
|
|
4
|
+
require 'concurrent'
|
|
5
|
+
|
|
6
|
+
module StarGates
|
|
7
|
+
class Feature
|
|
8
|
+
attr_reader :rollout_percentage, :group_matcher
|
|
9
|
+
|
|
10
|
+
def initialize(rollout: 0, group: nil)
|
|
11
|
+
@rollout_percentage = parse_rollout(rollout)
|
|
12
|
+
@group_matcher = group
|
|
13
|
+
@created_at = Time.now
|
|
14
|
+
@transition_history = Concurrent::Array.new
|
|
15
|
+
super()
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# State machine for feature lifecycle
|
|
19
|
+
state_machine :status, initial: :disabled do
|
|
20
|
+
# State: Disabled - Feature is completely off
|
|
21
|
+
state :disabled do
|
|
22
|
+
def execute(context = {})
|
|
23
|
+
StarGates.instrument('feature_disabled', feature: self.class.name, context: context)
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ui_component(_context = {})
|
|
28
|
+
'default_component'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def config_value(key, _context = {})
|
|
32
|
+
default_config[key]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# State: Enabled - Feature is fully rolled out
|
|
37
|
+
state :enabled do
|
|
38
|
+
def execute(context = {})
|
|
39
|
+
StarGates.instrument('feature_enabled', feature: self.class.name, context: context)
|
|
40
|
+
if block_given?
|
|
41
|
+
yield
|
|
42
|
+
else
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ui_component(_context = {})
|
|
48
|
+
'feature_component'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def config_value(key, _context = {})
|
|
52
|
+
feature_config[key]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# State: Canary - Feature is in gradual rollout
|
|
57
|
+
state :canary do
|
|
58
|
+
def execute(context = {})
|
|
59
|
+
if eligible_for_rollout?(context)
|
|
60
|
+
StarGates.instrument('feature_canary_enabled', feature: self.class.name, context: context)
|
|
61
|
+
if block_given?
|
|
62
|
+
yield
|
|
63
|
+
else
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
StarGates.instrument('feature_canary_disabled', feature: self.class.name, context: context)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ui_component(context = {})
|
|
73
|
+
if eligible_for_rollout?(context)
|
|
74
|
+
'feature_component'
|
|
75
|
+
else
|
|
76
|
+
'default_component'
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def config_value(key, context = {})
|
|
81
|
+
if eligible_for_rollout?(context)
|
|
82
|
+
feature_config[key]
|
|
83
|
+
else
|
|
84
|
+
default_config[key]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Events for state transitions
|
|
90
|
+
event :enable do
|
|
91
|
+
transition %i[disabled canary] => :enabled
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
event :disable do
|
|
95
|
+
transition %i[enabled canary] => :disabled
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
event :rollout_canary do
|
|
99
|
+
transition %i[disabled enabled] => :canary
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Transition tracking callbacks
|
|
103
|
+
after_transition any => any do |feature, transition|
|
|
104
|
+
feature.send(:record_transition, transition)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def enabled_for?(context = {})
|
|
109
|
+
case status
|
|
110
|
+
when 'enabled'
|
|
111
|
+
true
|
|
112
|
+
when 'canary'
|
|
113
|
+
eligible_for_rollout?(context)
|
|
114
|
+
else
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def enabled?
|
|
120
|
+
!disabled?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check if user is eligible for rollout based on percentage and group
|
|
124
|
+
def eligible_for_rollout?(context = {})
|
|
125
|
+
return group_matches?(context) if group_matcher
|
|
126
|
+
return false if rollout_percentage.zero?
|
|
127
|
+
return true if rollout_percentage == 100
|
|
128
|
+
|
|
129
|
+
user_id = extract_user_id(context)
|
|
130
|
+
return false unless user_id
|
|
131
|
+
|
|
132
|
+
user_hash = hash_user_id(user_id)
|
|
133
|
+
(user_hash % 100) < rollout_percentage
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Configuration methods for state-specific behavior
|
|
137
|
+
def feature_config
|
|
138
|
+
@feature_config ||= {}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def default_config
|
|
142
|
+
@default_config ||= {}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ============================================================================
|
|
146
|
+
# INTROSPECTION METHODS
|
|
147
|
+
# ============================================================================
|
|
148
|
+
|
|
149
|
+
# Get current rollout statistics
|
|
150
|
+
def stats
|
|
151
|
+
{
|
|
152
|
+
name: self.class.name,
|
|
153
|
+
status: status,
|
|
154
|
+
status_name: status_name,
|
|
155
|
+
rollout_percentage: rollout_percentage,
|
|
156
|
+
group_matcher: group_matcher_info,
|
|
157
|
+
created_at: @created_at,
|
|
158
|
+
state_duration: state_duration,
|
|
159
|
+
total_transitions: transition_count
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get complete feature configuration
|
|
164
|
+
def configuration
|
|
165
|
+
{
|
|
166
|
+
rollout_percentage: rollout_percentage,
|
|
167
|
+
group_matcher: group_matcher,
|
|
168
|
+
feature_config: feature_config.dup,
|
|
169
|
+
default_config: default_config.dup,
|
|
170
|
+
state_machine_config: state_machine_config
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Human-readable feature status summary
|
|
175
|
+
def summary
|
|
176
|
+
case status
|
|
177
|
+
when 'disabled'
|
|
178
|
+
"Feature '#{feature_name}' is DISABLED. No users have access."
|
|
179
|
+
when 'enabled'
|
|
180
|
+
"Feature '#{feature_name}' is ENABLED for all users (100% rollout)."
|
|
181
|
+
when 'canary'
|
|
182
|
+
if group_matcher
|
|
183
|
+
"Feature '#{feature_name}' is in CANARY mode for group #{group_matcher} (#{rollout_percentage}% rollout)."
|
|
184
|
+
else
|
|
185
|
+
"Feature '#{feature_name}' is in CANARY mode with #{rollout_percentage}% rollout."
|
|
186
|
+
end
|
|
187
|
+
else
|
|
188
|
+
"Feature '#{feature_name}' is in unknown state: #{status}."
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Complete introspection data export
|
|
193
|
+
def to_h
|
|
194
|
+
{
|
|
195
|
+
name: feature_name,
|
|
196
|
+
class: self.class.name,
|
|
197
|
+
status: status,
|
|
198
|
+
stats: stats,
|
|
199
|
+
configuration: configuration,
|
|
200
|
+
summary: summary,
|
|
201
|
+
introspected_at: Time.now
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Mock user struct for testing (Data.define is Ruby 3.2+)
|
|
206
|
+
MockUser = Data.define(:id, :groups) unless defined?(self::MockUser)
|
|
207
|
+
|
|
208
|
+
# Test what a specific user would experience
|
|
209
|
+
def simulate_for_user(user_id:, user_groups: [])
|
|
210
|
+
mock_user = MockUser.new(id: user_id, groups: user_groups)
|
|
211
|
+
context = { user: mock_user }
|
|
212
|
+
|
|
213
|
+
eligible = enabled_for?(context)
|
|
214
|
+
ui_component_result = ui_component(context)
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
user_id: user_id,
|
|
218
|
+
user_groups: user_groups,
|
|
219
|
+
eligible: eligible,
|
|
220
|
+
reason: eligibility_reason(context),
|
|
221
|
+
ui_component: ui_component_result,
|
|
222
|
+
would_execute: eligible,
|
|
223
|
+
feature_status: status
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Debug why a user is or isn't eligible
|
|
228
|
+
def eligibility_debug(user:)
|
|
229
|
+
context = { user: user }
|
|
230
|
+
user_id = extract_user_id(context)
|
|
231
|
+
|
|
232
|
+
debug_info = {
|
|
233
|
+
user_id: user_id,
|
|
234
|
+
feature_status: status,
|
|
235
|
+
rollout_percentage: rollout_percentage,
|
|
236
|
+
group_matcher: group_matcher,
|
|
237
|
+
eligible: enabled_for?(context)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case status
|
|
241
|
+
when 'disabled'
|
|
242
|
+
debug_info[:reason] = 'Feature is disabled'
|
|
243
|
+
when 'enabled'
|
|
244
|
+
debug_info[:reason] = 'Feature is fully enabled'
|
|
245
|
+
when 'canary'
|
|
246
|
+
if group_matcher
|
|
247
|
+
group_match = group_matches?(context)
|
|
248
|
+
debug_info[:group_match] = group_match
|
|
249
|
+
debug_info[:reason] = group_match ? 'User matches group criteria' : 'User does not match group criteria'
|
|
250
|
+
elsif user_id
|
|
251
|
+
user_hash = hash_user_id(user_id)
|
|
252
|
+
debug_info[:user_hash] = user_hash
|
|
253
|
+
debug_info[:hash_modulo] = user_hash % 100
|
|
254
|
+
debug_info[:reason] = if (user_hash % 100) < rollout_percentage
|
|
255
|
+
"User hash #{user_hash % 100} is within rollout #{rollout_percentage}%"
|
|
256
|
+
else
|
|
257
|
+
"User hash #{user_hash % 100} is outside rollout #{rollout_percentage}%"
|
|
258
|
+
end
|
|
259
|
+
else
|
|
260
|
+
debug_info[:reason] = 'No user ID found in context'
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
debug_info
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Test different rollout scenarios
|
|
268
|
+
def test_rollout_scenarios(percentages, sample_user_ids: (1..1000).to_a.sample(100))
|
|
269
|
+
scenarios = {}
|
|
270
|
+
|
|
271
|
+
percentages.each do |percentage|
|
|
272
|
+
eligible_count = 0
|
|
273
|
+
sample_user_ids.each do |user_id|
|
|
274
|
+
user_hash = hash_user_id(user_id)
|
|
275
|
+
eligible_count += 1 if (user_hash % 100) < percentage
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
scenarios[percentage] = {
|
|
279
|
+
percentage: percentage,
|
|
280
|
+
sample_size: sample_user_ids.size,
|
|
281
|
+
eligible_users: eligible_count,
|
|
282
|
+
estimated_percentage: (eligible_count.to_f / sample_user_ids.size * 100).round(2)
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
scenarios
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Get transition history for this feature
|
|
290
|
+
def transition_history(limit: 10)
|
|
291
|
+
{
|
|
292
|
+
feature_name: feature_name,
|
|
293
|
+
total_transitions: @transition_history.size,
|
|
294
|
+
transitions: @transition_history.last(limit).reverse.map do |transition|
|
|
295
|
+
{
|
|
296
|
+
timestamp: transition[:timestamp],
|
|
297
|
+
from_state: transition[:from_state],
|
|
298
|
+
to_state: transition[:to_state],
|
|
299
|
+
event: transition[:event],
|
|
300
|
+
feature_name: transition[:feature_name],
|
|
301
|
+
feature_class: transition[:feature_class],
|
|
302
|
+
duration_since: Time.now - transition[:timestamp]
|
|
303
|
+
}
|
|
304
|
+
end,
|
|
305
|
+
limit: limit
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Helper methods for introspection
|
|
310
|
+
def feature_name
|
|
311
|
+
@feature_name ||= self.class.name&.split('::')&.last || 'UnnamedFeature'
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Get transition count
|
|
315
|
+
def transition_count
|
|
316
|
+
@transition_history.size
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
def group_matcher_info
|
|
322
|
+
case group_matcher
|
|
323
|
+
when Symbol
|
|
324
|
+
{ type: 'symbol', value: group_matcher }
|
|
325
|
+
when Proc
|
|
326
|
+
{ type: 'proc', value: '<Proc>' }
|
|
327
|
+
when nil
|
|
328
|
+
nil
|
|
329
|
+
else
|
|
330
|
+
{ type: group_matcher.class.name, value: group_matcher.to_s }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def state_duration
|
|
335
|
+
last_transition = @transition_history.last
|
|
336
|
+
return 'No transitions yet' unless last_transition
|
|
337
|
+
|
|
338
|
+
duration = Time.now - last_transition[:timestamp]
|
|
339
|
+
if duration < 60
|
|
340
|
+
"#{duration.round(1)}s"
|
|
341
|
+
elsif duration < 3600
|
|
342
|
+
"#{(duration / 60).round(1)}m"
|
|
343
|
+
else
|
|
344
|
+
"#{(duration / 3600).round(1)}h"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def state_machine_config
|
|
349
|
+
# Extract state machine configuration
|
|
350
|
+
machine = self.class.state_machines[:status]
|
|
351
|
+
return {} unless machine
|
|
352
|
+
|
|
353
|
+
initial_state_obj = machine.initial_state(self)
|
|
354
|
+
initial_state = initial_state_obj.respond_to?(:name) ? initial_state_obj.name : initial_state_obj
|
|
355
|
+
|
|
356
|
+
{
|
|
357
|
+
initial_state: initial_state,
|
|
358
|
+
states: machine.states.map(&:name),
|
|
359
|
+
events: machine.events.map(&:name)
|
|
360
|
+
}
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def eligibility_reason(context)
|
|
364
|
+
case status
|
|
365
|
+
when 'disabled'
|
|
366
|
+
'Feature is disabled'
|
|
367
|
+
when 'enabled'
|
|
368
|
+
'Feature is fully enabled'
|
|
369
|
+
when 'canary'
|
|
370
|
+
eligible_for_rollout?(context) ? 'User is eligible for rollout' : 'User is not eligible for rollout'
|
|
371
|
+
else
|
|
372
|
+
"Unknown status: #{status}"
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def parse_rollout(rollout)
|
|
377
|
+
case rollout
|
|
378
|
+
when Numeric then rollout.to_i
|
|
379
|
+
when String then rollout.delete_suffix('%').to_i
|
|
380
|
+
else 0
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def group_matches?(context)
|
|
385
|
+
return false unless group_matcher
|
|
386
|
+
|
|
387
|
+
user = context[:user]
|
|
388
|
+
return false unless user
|
|
389
|
+
|
|
390
|
+
case group_matcher
|
|
391
|
+
when Symbol
|
|
392
|
+
user.respond_to?(:groups) && user.groups.include?(group_matcher)
|
|
393
|
+
when Proc
|
|
394
|
+
group_matcher.call(user)
|
|
395
|
+
else
|
|
396
|
+
false
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def extract_user_id(context)
|
|
401
|
+
user = context[:user]
|
|
402
|
+
return nil unless user
|
|
403
|
+
|
|
404
|
+
if user.respond_to?(:id)
|
|
405
|
+
user.id
|
|
406
|
+
elsif user.respond_to?(:user_id)
|
|
407
|
+
user.user_id
|
|
408
|
+
elsif user.is_a?(String) || user.is_a?(Integer)
|
|
409
|
+
user
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def hash_user_id(user_id)
|
|
414
|
+
user_id.to_s.hash.abs
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Record state transition with timestamp and metadata
|
|
418
|
+
def record_transition(transition)
|
|
419
|
+
# Convert state values to symbols for consistency
|
|
420
|
+
from_state = transition.from.is_a?(String) ? transition.from.to_sym : transition.from
|
|
421
|
+
to_state = transition.to.is_a?(String) ? transition.to.to_sym : transition.to
|
|
422
|
+
|
|
423
|
+
transition_record = {
|
|
424
|
+
timestamp: Time.now,
|
|
425
|
+
from_state: from_state,
|
|
426
|
+
to_state: to_state,
|
|
427
|
+
event: transition.event,
|
|
428
|
+
feature_name: feature_name,
|
|
429
|
+
feature_class: self.class.name
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@transition_history << transition_record
|
|
433
|
+
|
|
434
|
+
# Keep only the last 50 transitions to prevent memory bloat
|
|
435
|
+
return unless @transition_history.size > 50
|
|
436
|
+
|
|
437
|
+
@transition_history.replace(@transition_history.last(50))
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
class PercentageHelper
|
|
442
|
+
def self.percent(value)
|
|
443
|
+
value
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StarGates
|
|
4
|
+
# Boolean flags with environment > configuration > runtime > false precedence.
|
|
5
|
+
# Runtime state uses Feature's lifecycle and is local to this process.
|
|
6
|
+
class Flags
|
|
7
|
+
attr_accessor :configuration
|
|
8
|
+
|
|
9
|
+
def initialize(env: ENV, configuration: {})
|
|
10
|
+
@env = env
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
@runtime = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def enabled?(name)
|
|
17
|
+
debug(name)[:enabled]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def set(name, value)
|
|
21
|
+
update(name, value)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enable(name, metadata: {})
|
|
25
|
+
update(name, true, metadata: metadata)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def disable(name)
|
|
29
|
+
update(name, false)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enable_if(name)
|
|
33
|
+
set(name, yield)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remove(name)
|
|
37
|
+
@mutex.synchronize { @runtime.delete(normalize(name)) }
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset!
|
|
42
|
+
@mutex.synchronize { @runtime.clear }
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def all
|
|
47
|
+
configured = configuration_flags
|
|
48
|
+
names = (environment_flags.keys + configured.keys + runtime_names).uniq
|
|
49
|
+
names.to_h { |name| [name, resolution(name, configured).slice(:enabled, :source)] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def enabled_flags
|
|
53
|
+
all.select { |_, flag| flag[:enabled] }.keys.map(&:to_sym)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def summary
|
|
57
|
+
{
|
|
58
|
+
runtime_flags: runtime_names.map(&:to_sym),
|
|
59
|
+
environment_flags: environment_flags.keys.map(&:to_sym),
|
|
60
|
+
configuration_flags: configuration_flags.keys.map(&:to_sym),
|
|
61
|
+
all_enabled: enabled_flags
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def debug(name)
|
|
66
|
+
resolution(normalize(name), configuration_flags)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def flag_status(name)
|
|
70
|
+
normalized = normalize(name)
|
|
71
|
+
details = debug(normalized)
|
|
72
|
+
metadata = @mutex.synchronize { @runtime[normalized]&.fetch(:metadata)&.dup || {} }
|
|
73
|
+
{ name: normalized&.to_sym, enabled: details[:enabled], source: details[:source], metadata: metadata }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def normalize(name)
|
|
79
|
+
return unless name.is_a?(String) || name.is_a?(Symbol)
|
|
80
|
+
|
|
81
|
+
normalized = name.to_s.tr('-', '_').downcase
|
|
82
|
+
normalized if normalized.match?(/\A[a-z0-9_]+\z/)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def boolean(value)
|
|
86
|
+
value == true || (value.is_a?(String) && %w[true 1 yes on].include?(value.downcase))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def update(name, value, metadata: nil)
|
|
90
|
+
normalized = normalize(name)
|
|
91
|
+
raise ArgumentError, 'flag name must be a nonempty string or symbol' unless normalized
|
|
92
|
+
|
|
93
|
+
enabled = boolean(value)
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
entry = @runtime[normalized] ||= { feature: Feature.new, metadata: {} }
|
|
96
|
+
feature = entry.fetch(:feature)
|
|
97
|
+
feature.enable if enabled && feature.disabled?
|
|
98
|
+
feature.disable if !enabled && !feature.disabled?
|
|
99
|
+
entry[:metadata] = metadata.dup if metadata
|
|
100
|
+
end
|
|
101
|
+
enabled
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def runtime_names
|
|
105
|
+
@mutex.synchronize { @runtime.keys }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def configuration_flags
|
|
109
|
+
values = configuration.is_a?(Proc) ? configuration.call : configuration
|
|
110
|
+
raise ArgumentError, 'configuration must be a Hash or a Proc returning a Hash' unless values.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
values.each_with_object({}) do |(name, value), flags|
|
|
113
|
+
normalized = normalize(name)
|
|
114
|
+
flags[normalized] = value if normalized && [true, false].include?(value)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def environment_flags
|
|
119
|
+
@env.each_pair.with_object({}) do |(key, value), flags|
|
|
120
|
+
next unless key.start_with?('FEATURE_') && !value.to_s.strip.empty?
|
|
121
|
+
|
|
122
|
+
name = normalize(key.delete_prefix('FEATURE_'))
|
|
123
|
+
flags[name] = value if name && key == "FEATURE_#{name.upcase}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def resolution(name, configured)
|
|
128
|
+
env_key = "FEATURE_#{name&.upcase}"
|
|
129
|
+
environment_value = name && @env[env_key]
|
|
130
|
+
runtime_value = @mutex.synchronize { @runtime[name]&.fetch(:feature)&.enabled_for? }
|
|
131
|
+
|
|
132
|
+
value, source = if name && !environment_value.to_s.strip.empty?
|
|
133
|
+
[boolean(environment_value), "environment:#{env_key}"]
|
|
134
|
+
elsif configured.key?(name)
|
|
135
|
+
[configured.fetch(name), "config:#{name}"]
|
|
136
|
+
elsif !runtime_value.nil?
|
|
137
|
+
[runtime_value, 'runtime']
|
|
138
|
+
else
|
|
139
|
+
[false, 'default']
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
flag: name&.to_sym,
|
|
144
|
+
enabled: value,
|
|
145
|
+
value: value,
|
|
146
|
+
source: source,
|
|
147
|
+
environment_value: environment_value,
|
|
148
|
+
configuration_value: configured[name],
|
|
149
|
+
runtime_value: runtime_value
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StarGates
|
|
4
|
+
class Gate
|
|
5
|
+
attr_reader :name, :feature, :circuit, :run_block, :fallback_block
|
|
6
|
+
|
|
7
|
+
def initialize(name, feature: nil, circuit: nil, run_block: nil, fallback_block: nil)
|
|
8
|
+
@name = name
|
|
9
|
+
@feature = feature
|
|
10
|
+
@circuit = circuit
|
|
11
|
+
@run_block = run_block
|
|
12
|
+
@fallback_block = fallback_block
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(context = {})
|
|
16
|
+
# Use the new state-specific execute method if feature supports it
|
|
17
|
+
if feature.respond_to?(:execute)
|
|
18
|
+
result = if circuit
|
|
19
|
+
circuit.call { feature.execute(context) { execute_run_block(context) } }
|
|
20
|
+
else
|
|
21
|
+
feature.execute(context) { execute_run_block(context) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# If feature returns nil (disabled), execute fallback
|
|
25
|
+
return result unless result.nil?
|
|
26
|
+
|
|
27
|
+
execute_fallback(context)
|
|
28
|
+
else
|
|
29
|
+
# Fallback to old behavior for compatibility
|
|
30
|
+
return execute_fallback(context) unless feature_enabled?(context)
|
|
31
|
+
|
|
32
|
+
if circuit
|
|
33
|
+
circuit.call { execute_run_block(context) }
|
|
34
|
+
else
|
|
35
|
+
execute_run_block(context)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
rescue StarGates::UnavailableError, StandardError
|
|
39
|
+
execute_fallback(context)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def enabled_for?(context = {})
|
|
43
|
+
feature_enabled?(context) && !circuit_open?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def open?
|
|
47
|
+
circuit&.open? || false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def closed?
|
|
51
|
+
!open?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def status
|
|
55
|
+
{
|
|
56
|
+
name: name,
|
|
57
|
+
feature_enabled: feature&.enabled? || true,
|
|
58
|
+
feature_status: feature.respond_to?(:status) ? feature.status : nil,
|
|
59
|
+
circuit_open: circuit&.open? || false,
|
|
60
|
+
last_failure: circuit&.last_failure_time
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def feature_enabled?(context)
|
|
67
|
+
return true unless feature
|
|
68
|
+
|
|
69
|
+
feature.enabled_for?(context)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def circuit_open?
|
|
73
|
+
circuit&.open? || false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def execute_run_block(context)
|
|
77
|
+
return unless run_block
|
|
78
|
+
|
|
79
|
+
if run_block.arity == 1
|
|
80
|
+
run_block.call(context)
|
|
81
|
+
else
|
|
82
|
+
run_block.call
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def execute_fallback(context)
|
|
87
|
+
return raise_unavailable unless fallback_block
|
|
88
|
+
|
|
89
|
+
if fallback_block.arity == 1
|
|
90
|
+
fallback_block.call(context)
|
|
91
|
+
else
|
|
92
|
+
fallback_block.call
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def raise_unavailable
|
|
97
|
+
raise StarGates::UnavailableError, "Gate #{name} is unavailable and no fallback is defined"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|