flagger 2.0.5 → 3.0.1

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.
@@ -1,655 +0,0 @@
1
- require 'digest'
2
- require 'dry-validation'
3
-
4
- module FlaggerModels
5
- class BaseEntity
6
- SCHEMA = Dry::Validation.Schema do
7
- configure do
8
- def valid_attribute_names?(attributes)
9
- attributes.keys.all? do |name|
10
- name.is_a?(String) && /^[a-zA-Z]$|^[a-zA-Z_]{0,48}[a-zA-Z]$/ =~ name
11
- end
12
- end
13
-
14
- def valid_attribute_values?(attributes)
15
- attributes.values.all? do |v|
16
- (v.is_a?(String) && v.length <= 3000) || [true, false].include?(v) || v.is_a?(Numeric)
17
- end
18
- end
19
-
20
- def self.messages
21
- super.merge(
22
- en: {
23
- errors: {
24
- :valid_attribute_names? => 'Each attribute must begin and end with an alphabet letter (a-z, A-Z). In between, allowed characters are a-z, A-Z, and "_". For example: isStudent or is_student. Preceding or trailing underscore is not allowed (i.e., _is_student or is_student_).',
25
- :valid_attribute_values? => "An entity's attribute value must be a string no more than 3000 characters, boolean, or number."
26
- }
27
- }
28
- )
29
- end
30
- end
31
-
32
- optional('type') { str? & size?(1..50) & format?(/^([A-Z][a-zA-Z]*)+$/) }
33
- required('id') { str? & size?(1..250) }
34
- optional('display_name') { str? & size?(1..250) }
35
- optional('attributes') { type?(Hash) & size?(0..100) & valid_attribute_names? & valid_attribute_values? }
36
- required('is_group') { bool? }
37
- end
38
-
39
- attr_accessor :type
40
- attr_accessor :id
41
- attr_accessor :display_name
42
- attr_accessor :attributes
43
- attr_accessor :is_group
44
-
45
- def initialize(json)
46
- @type = json['type']
47
- @id = json['id']
48
- if @id.is_a?(Numeric) then
49
- @id = @id.to_s
50
- end
51
- @display_name = json['display_name'] || @id if @id.is_a?(String)
52
- @attributes = json['attributes'] || {}
53
- end
54
-
55
- def attrs()
56
- instance_variables.map do |var|
57
- if var == :@group then
58
- group = instance_variable_get(var)
59
- [var[1..-1], if defined? group.attrs then group.attrs else group end]
60
- else
61
- [var[1..-1], instance_variable_get(var)]
62
- end
63
- end.to_h.delete_if { |k,v| v.nil? }
64
- end
65
-
66
- def hash()
67
- attrs.hash
68
- end
69
-
70
- def validation_errors()
71
- SCHEMA.(attrs).errors
72
- end
73
-
74
- def valid?()
75
- validation_errors.length === 0
76
- end
77
- end
78
-
79
- class GroupEntity < BaseEntity
80
- def initialize(json)
81
- super(json)
82
- @is_group = true
83
- end
84
- end
85
-
86
- class SingleEntity < BaseEntity
87
- SCHEMA = Dry::Validation.Schema do
88
- optional('group').schema(GroupEntity::SCHEMA)
89
- end
90
-
91
- attr_accessor :group
92
-
93
- def initialize(json)
94
- super(json)
95
- @group = json['group']&.is_a?(Hash) && GroupEntity.new(json['group'])
96
- @is_group = false
97
-
98
- if @type == nil then
99
- @type = 'User'
100
- end
101
-
102
- if @group && @group.type.nil? then
103
- @group.type = @type + 'Group'
104
- end
105
- end
106
-
107
- def validation_errors()
108
- if @group != nil then
109
- group_errors = SingleEntity::SCHEMA.(attrs).errors
110
- super.merge(group_errors)
111
- else
112
- super
113
- end
114
- end
115
- end
116
-
117
- class GatingInfoModel
118
- def validate()
119
- messages = self.class::SCHEMA.(attrs).messages
120
- raise RuntimeError.new(messages) if messages.length > 0
121
- end
122
-
123
- def attrs()
124
- instance_variables.map do |var|
125
- value = instance_variable_get(var)
126
- if value != nil then
127
- [var, value]
128
- else
129
- nil
130
- end
131
- end.compact.to_h
132
- end
133
- end
134
-
135
- class GatingInfo
136
- attr_reader :sdk_info
137
- attr_reader :flags
138
- attr_reader :env
139
-
140
- def initialize(json)
141
- @sdk_info = SdkInfo.new(json['sdk_info'] || {})
142
- @env = Env.new(json['env'])
143
- @flags = Hash[json['flags'].map {|flag| [flag['codename'], Flag.new(flag, @env.hash_key)]}]
144
- end
145
-
146
- def flag(name)
147
- @flags[name]
148
- end
149
- end
150
-
151
- class SdkInfo < GatingInfoModel
152
- SCHEMA = Dry::Validation.Schema do
153
- optional(:@ingestion_interval) { int? }
154
- optional(:@ingestion_max_items) { int? }
155
- optional(:@should_ingest_entities) { bool? }
156
- optional(:@should_ingest_stats) { bool? }
157
- optional(:@should_ingest_exposures) { bool? }
158
- optional(:@should_ingest_flags) { bool? }
159
- end
160
-
161
- attr_reader :ingestion_interval
162
- attr_reader :ingestion_max_items
163
- attr_reader :should_ingest_entities
164
- attr_reader :should_ingest_stats
165
- attr_reader :should_ingest_exposures
166
- attr_reader :should_ingest_flags
167
-
168
- def initialize(json)
169
- @ingestion_interval = json['SDK_INGESTION_INTERVAL']
170
- @ingestion_max_items = json['SDK_INGESTION_MAX_ITEMS']
171
- @should_ingest_entities = json['SDK_SHOULD_INGEST_OBJECTS']
172
- @should_ingest_stats = json['SDK_SHOULD_INGEST_STATS']
173
- @should_ingest_exposures = json['SDK_SHOULD_INGEST_EXPOSURES']
174
- @should_ingest_flags = json['SDK_SHOULD_INGEST_FLAGS']
175
-
176
- validate
177
- end
178
- end
179
-
180
- class Env < GatingInfoModel
181
- SCHEMA = Dry::Validation.Schema do
182
- required(:@env_key) { str? }
183
- required(:@hash_key) { str? }
184
- end
185
-
186
- attr_reader :env_key
187
- attr_reader :hash_key
188
-
189
- def initialize(json)
190
- @env_key = json['env_key']
191
- @hash_key = json['hash_key']
192
-
193
- validate
194
- end
195
- end
196
-
197
- class Flag < GatingInfoModel
198
- SCHEMA = Dry::Validation.Schema do
199
- required(:@codename) { str? }
200
- required(:@flag_status) { included_in? ['operational', 'archived'] }
201
- required(:@flag_type) { included_in? ['basic', 'experiment', 'uncategorized'] }
202
- required(:@hash_key) { str? }
203
- optional(:@is_paused) { bool? }
204
- required(:@is_web_accessible) { bool? }
205
- required(:@overrides) { hash? }
206
- required(:@populations) { array? }
207
- required(:@splits) { hash? }
208
- required(:@treatments) { hash? }
209
-
210
- required(:@env_hash_key) { str? }
211
- end
212
-
213
- attr_reader :codename
214
- attr_reader :flag_status
215
- attr_reader :flag_type
216
- attr_reader :hash_key
217
- attr_reader :is_paused
218
- attr_reader :is_web_accessible
219
- attr_reader :overrides
220
- attr_reader :populations
221
- attr_reader :splits
222
- attr_reader :treatments
223
-
224
- attr_reader :env_hash_key
225
-
226
- def initialize(json, env_hash_key)
227
- @codename = json['codename']
228
- @flag_status = json['flag_status']
229
- @flag_type = json['flag_type']
230
- @hash_key = json['hash_key']
231
- @is_paused = json['is_paused']
232
- @is_web_accessible = json['is_web_accessible']
233
- @overrides = Hash[(json['overrides'] || []).map {|override| ["#{override['entity_type']}_#{override['entity_id']}", Override.new(override)]}]
234
- @populations = (json['populations'] || []).map {|population| Population.new(population)}
235
- @splits = Hash[(json['splits'] || []).map {|split| [split['treatment_id'], Split.new(split)]}]
236
- @treatments = Hash[(json['treatments'] || []).map {|treatment| [treatment['treatment_id'], Treatment.new(treatment)]}]
237
-
238
- @env_hash_key = env_hash_key
239
-
240
- validate
241
- end
242
-
243
- def treatment(entity)
244
- if @flag_type == 'uncategorized' or !entity then
245
- nil
246
- else
247
- resolved_allocation(@env_hash_key, entity)
248
- end
249
- end
250
-
251
- def payload(entity)
252
- if @flag_type == 'uncategorized' or !entity then
253
- nil
254
- else
255
- resolved_allocation(@env_hash_key, entity)
256
- end
257
- end
258
-
259
- def eligible?(entity)
260
- if @flag_type == 'uncategorized' or !entity then
261
- false
262
- else
263
- resolved_allocation(@env_hash_key, entity)
264
- end
265
- end
266
-
267
- def enabled?(entity)
268
- if @flag_type == 'uncategorized' or !entity then
269
- false
270
- else
271
- resolved_allocation(@env_hash_key, entity)
272
- end
273
- end
274
-
275
- def resolved_allocation(env_hash_key, entity)
276
- resolve_allocations(
277
- allocation(@env_hash_key, entity),
278
- allocation(@env_hash_key, (entity.group if defined? entity.group))
279
- )
280
- end
281
-
282
- def allocation(env_hash_key, entity)
283
- off_allocation = {
284
- treatment: @treatments.find {|treatment_id, treatment| treatment.is_off_treatment}[1],
285
- eligible: false
286
- }
287
-
288
- if !entity.is_a?(BaseEntity) then
289
- return off_allocation
290
- end
291
-
292
- if @flag_status == 'archived' then
293
- STDERR.puts 'The flag has been archived'
294
- return off_allocation
295
- end
296
-
297
- if @is_paused then
298
- return off_allocation
299
- end
300
-
301
- treatment = @treatments[@overrides["#{entity.type}_#{entity.id}"]&.treatment_id]
302
- if treatment then
303
- return {
304
- treatment: treatment,
305
- eligible: !treatment.is_off_treatment,
306
- from_override: true
307
- }
308
- end
309
-
310
- use_universes = @flag_type == 'experiment'
311
-
312
- is_eligible = false
313
- @populations.each do |population|
314
- eligible, treatment = population.gate_values(
315
- entity,
316
- env_hash_key,
317
- self,
318
- use_universes
319
- ).values_at(:eligible, :treatment)
320
-
321
- if treatment != nil then
322
- return {
323
- treatment: treatment,
324
- eligible: eligible
325
- }
326
- end
327
-
328
- is_eligible ||= eligible
329
- end
330
-
331
- {
332
- treatment: off_allocation[:treatment],
333
- eligible: is_eligible
334
- }
335
- end
336
-
337
- def resolve_allocations(individual_alloc, group_alloc)
338
- if individual_alloc[:from_override] then
339
- individual_alloc
340
- elsif group_alloc[:from_override] then
341
- group_alloc
342
- elsif not individual_alloc[:treatment].is_off_treatment then
343
- individual_alloc
344
- elsif not group_alloc[:treatment].is_off_treatment then
345
- group_alloc
346
- else
347
- individual_alloc
348
- end
349
- end
350
- end
351
-
352
- class Override < GatingInfoModel
353
- SCHEMA = Dry::Validation.Schema do
354
- required(:@treatment_id) { str? }
355
- required(:@entity_id) { str? }
356
- required(:@entity_type) { str? }
357
- end
358
-
359
- attr_reader :treatment_id
360
- attr_reader :entity_id
361
- attr_reader :entity_type
362
-
363
- def initialize(json)
364
- @treatment_id = json['treatment_id']
365
- @entity_id = json['entity_id']
366
- @entity_type = json['entity_type']
367
-
368
- validate
369
- end
370
- end
371
-
372
- class Population < GatingInfoModel
373
- SCHEMA = Dry::Validation.Schema do
374
- required(:@rules) { array? }
375
- required(:@universes) { array? }
376
- required(:@percentage) { gteq?(0) & lteq?(1) }
377
- required(:@hash_key) { str? }
378
- required(:@entity_type) { str? }
379
- end
380
-
381
- attr_reader :rules
382
- attr_reader :universes
383
- attr_reader :percentage
384
- attr_reader :hash_key
385
- attr_reader :entity_type
386
-
387
- def initialize(json)
388
- @rules = (json['rules'] || []).map {|rule| Rule.new(rule)}
389
- @universes = (json['universes'] || []).map {|universe| Hash[universe.map {|split| [split['treatment_id'], Split.new(split)]}]}
390
- @percentage = json['percentage']
391
- @hash_key = json['hash_key']
392
- @entity_type = json['entity_type']
393
-
394
- validate
395
- end
396
-
397
- def gate_values(entity, env_hash_key, flag, sticky)
398
- if @entity_type != entity.type
399
- return {eligible: false}
400
- end
401
-
402
- matches = @rules.all? {|rule| rule.match(entity)}
403
- if matches then
404
- samplingHashKey = "SAMPLING:control_#{flag.hash_key}:env_#{env_hash_key}:rule_set_#{@hash_key}:client_object_#{entity.type}_#{entity.id}"
405
- if Population.getHashedPercentage(samplingHashKey) <= @percentage and @percentage > 0 then
406
-
407
- allocationHashKey = "DISTRIBUTION:control_#{flag.hash_key}:env_#{env_hash_key}:client_object_#{entity.type}_#{entity.id}"
408
- allocationHashedPercentage = Population.getHashedPercentage(allocationHashKey)
409
-
410
- splits = if sticky then
411
- @universes[(@percentage * 100).floor - 1]
412
- else
413
- flag.splits
414
- end
415
-
416
- sum = 0
417
- {
418
- eligible: true,
419
- treatment: flag.treatments.find {|treatment_id, treatment|
420
- if treatment.is_off_treatment then
421
- false
422
- else
423
- sum = (sum + (splits[treatment.treatment_id]&.percentage || 0)).round(3)
424
- allocationHashedPercentage <= sum
425
- end
426
- }[1]
427
- }
428
- else
429
- {eligible: true}
430
- end
431
- else
432
- {eligible: false}
433
- end
434
- end
435
-
436
- def self.getHashedPercentage(s)
437
- Digest::MD5.hexdigest(s).to_i(16).to_f / 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
438
- end
439
- end
440
-
441
- module RuleType
442
- STRING = 'string'
443
- INT = 'int'
444
- FLOAT = 'float'
445
- BOOLEAN = 'boolean'
446
- DATE = 'date'
447
- DATETIME = 'datetime'
448
- end
449
-
450
- module RuleOperator
451
- IS = 'is'
452
- IS_NOT = 'is_not'
453
- IN = 'in'
454
- NOT_IN = 'not_in'
455
- LT = 'lt'
456
- LTE = 'lte'
457
- GT = 'gt'
458
- GTE = 'gte'
459
- FROM = 'from'
460
- UNTIL = 'until'
461
- AFTER = 'after'
462
- BEFORE = 'before'
463
- end
464
-
465
- class Rule < GatingInfoModel
466
- SCHEMA = Dry::Validation.Schema do
467
- required(:@operator) { included_in? RuleOperator.constants(false).map(&RuleOperator.method(:const_get)) }
468
- required(:@attribute_type) { included_in? RuleType.constants(false).map(&RuleType.method(:const_get)) }
469
- required(:@attribute_name) { str? }
470
- optional(:@value_list) { array? { each { str? | bool? | type?(Numeric) } } }
471
- optional(:@value) { str? | bool? | type?(Numeric) }
472
- end
473
-
474
- attr_reader :operator
475
- attr_reader :attribute_type
476
- attr_reader :attribute_name
477
- attr_reader :value_list
478
- attr_reader :value
479
-
480
- def initialize(json)
481
- @operator = json['operator']
482
- @attribute_type = json['attribute_type']
483
- @attribute_name = json['attribute_name']
484
- @value_list = json['value_list']
485
- @value = json['value']
486
-
487
- validate
488
- end
489
-
490
- def self.categorize_value_type(value)
491
- case value
492
- when true
493
- 'boolean'
494
- when false
495
- 'boolean'
496
- when Float
497
- 'float'
498
- when Integer
499
- 'int'
500
- when String
501
- begin
502
- date = DateTime.parse(value)
503
- if date.hour == 0 && date.minute == 0 && date.second == 0 && date.second_fraction == 0 then
504
- 'date'
505
- else
506
- 'datetime'
507
- end
508
- rescue ArgumentError
509
- 'string'
510
- end
511
- else
512
- STDERR.puts 'Unexpected attribute value type encountered'
513
- nil
514
- end
515
- end
516
-
517
- def match(entity)
518
- begin
519
- if not entity.attributes.key?(@attribute_name) then
520
- return false
521
- end
522
-
523
- value = entity.attributes[@attribute_name]
524
- attribute_type = Rule.categorize_value_type(value)
525
- number_types = [RuleType::INT, RuleType::FLOAT].sort
526
-
527
- if attribute_type != @attribute_type &&
528
- [attribute_type, @attribute_type].sort != number_types then
529
- return false
530
- end
531
-
532
- if attribute_type == RuleType::STRING then
533
- if @operator == RuleOperator::IS then
534
- value == @value
535
- elsif @operator == RuleOperator::IS_NOT then
536
- value != @value
537
- elsif @operator == RuleOperator::IN then
538
- @value_list.include?(value)
539
- elsif @operator == RuleOperator::NOT_IN then
540
- not @value_list.include?(value)
541
- else
542
- STDERR.puts 'Invalid rule operator encountered'
543
- false
544
- end
545
- elsif number_types.include?(attribute_type) then
546
- if @operator == RuleOperator::IS then
547
- value == @value
548
- elsif @operator == RuleOperator::IS_NOT then
549
- value != @value
550
- elsif @operator == RuleOperator::IN then
551
- @value_list.include?(value)
552
- elsif @operator == RuleOperator::NOT_IN then
553
- not @value_list.include?(value)
554
- elsif @operator == RuleOperator::LT then
555
- value < @value
556
- elsif @operator == RuleOperator::LTE then
557
- value <= @value
558
- elsif @operator == RuleOperator::GT then
559
- value > @value
560
- elsif @operator == RuleOperator::GTE then
561
- value >= @value
562
- else
563
- STDERR.puts 'Invalid rule operator encountered'
564
- false
565
- end
566
- elsif attribute_type == RuleType::BOOLEAN then
567
- if @operator == RuleOperator::IS then
568
- value == @value
569
- elsif @operator == RuleOperator::IS_NOT then
570
- value != @value
571
- else
572
- STDERR.puts 'Invalid rule operator encountered'
573
- false
574
- end
575
- elsif attribute_type == RuleType::DATE || attribute_type == RuleType::DATETIME then
576
- target_time = @value && DateTime.parse(@value)
577
- target_time_list = @value_list&.map {|tv| DateTime.parse(tv)}
578
- value_time = DateTime.parse(value)
579
-
580
- if @operator == RuleOperator::IS then
581
- value_time == target_time
582
- elsif @operator == RuleOperator::IS_NOT then
583
- value_time != target_time
584
- elsif @operator == RuleOperator::IN then
585
- target_time_list.include?(value_time)
586
- elsif @operator == RuleOperator::NOT_IN then
587
- not target_time_list.include?(value_time)
588
- elsif @operator == RuleOperator::FROM then
589
- value_time >= target_time
590
- elsif @operator == RuleOperator::UNTIL then
591
- value_time <= target_time
592
- elsif @operator == RuleOperator::AFTER then
593
- value_time > target_time
594
- elsif @operator == RuleOperator::BEFORE then
595
- value_time < target_time
596
- else
597
- STDERR.puts 'Invalid rule operator encountered'
598
- false
599
- end
600
- else
601
- STDERR.puts 'Invalid attribute type encountered'
602
- false
603
- end
604
- rescue Exception => e
605
- STDERR.puts "Traceback:"
606
- STDERR.puts e.backtrace
607
- STDERR.puts "#{e.class} (#{e.message})"
608
- false
609
- end
610
- end
611
- end
612
-
613
- class Split < GatingInfoModel
614
- SCHEMA = Dry::Validation.Schema do
615
- required(:@treatment_id) { str? }
616
- required(:@percentage) { gteq?(0) & lteq?(1) }
617
- end
618
-
619
- attr_reader :treatment_id
620
- attr_reader :percentage
621
-
622
- def initialize(json)
623
- @treatment_id = json['treatment_id']
624
- @percentage = json['percentage']
625
-
626
- validate
627
- end
628
- end
629
-
630
- class Treatment < GatingInfoModel
631
- SCHEMA = Dry::Validation.Schema do
632
- required(:@treatment_id) { str? }
633
- required(:@is_control) { bool? }
634
- required(:@codename) { str? }
635
- required(:@is_off_treatment) { bool? }
636
- optional(:@payload) { str? | type?(Numeric) | bool? | array? | type?(Hash) }
637
- end
638
-
639
- attr_reader :treatment_id
640
- attr_reader :is_control
641
- attr_reader :codename
642
- attr_reader :is_off_treatment
643
- attr_reader :payload
644
-
645
- def initialize(json)
646
- @treatment_id = json['treatment_id']
647
- @is_control = json['is_control']
648
- @codename = json['codename']
649
- @is_off_treatment = json['is_off_treatment']
650
- @payload = json['payload']
651
-
652
- validate
653
- end
654
- end
655
- end