flagger 2.0.8 → 3.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.
@@ -1,63 +0,0 @@
1
- require 'faraday'
2
- require 'json'
3
-
4
- module FlaggerEnvironments
5
- class MicroserviceDelegate
6
- attr_accessor :env_key
7
- attr_accessor :edge_url
8
- attr_accessor :request_timeout
9
-
10
- def initialize(env_key, edge_url, request_timeout)
11
- @env_key = env_key
12
- @edge_url = edge_url
13
- @request_timeout = request_timeout
14
- end
15
-
16
- def treatment(flag, entity)
17
- get_object_values(flag, entity)['treatment'] rescue nil
18
- end
19
-
20
- def payload(flag, entity)
21
- get_object_values(flag, entity)['payload'] rescue nil
22
- end
23
-
24
- def eligible?(flag, entity)
25
- get_object_values(flag, entity)['isEligible'] rescue nil
26
- end
27
-
28
- def enabled?(flag, entity)
29
- get_object_values(flag, entity)['isEnabled'] rescue nil
30
- end
31
-
32
- def publish(entities)
33
- end
34
-
35
- def cleanup()
36
- end
37
-
38
- private
39
- def get_object_values(flag, entity)
40
- begin
41
- conn = Faraday.new(url: "#{@edge_url}/v2/object-values/#{@env_key}")
42
- response = conn.post do |req|
43
- req.options.timeout = @request_timeout
44
- req.headers['Content-Type'] = 'application/json'
45
- req.body = JSON.generate({
46
- 'flag' => flag.flag_name,
47
- 'entity' => entity,
48
- })
49
- end
50
- if response.status == 200
51
- object_values = JSON.parse(response.body)
52
- object_values
53
- else
54
- puts 'Failed to connect to Airship edge server'
55
- nil
56
- end
57
- rescue Exception => e
58
- puts 'Failed to connect to Airship edge server', e
59
- nil
60
- end
61
- end
62
- end
63
- end
@@ -1,657 +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
- @off_treatment = json['treatments'].find {|treatment| treatment['is_off_treatment'] }
238
-
239
- @env_hash_key = env_hash_key
240
-
241
- validate
242
- end
243
-
244
- def treatment(entity)
245
- if @flag_type == 'uncategorized' or !entity then
246
- nil
247
- else
248
- resolved_allocation(@env_hash_key, entity)
249
- end
250
- end
251
-
252
- def payload(entity)
253
- if @flag_type == 'uncategorized' or !entity then
254
- nil
255
- else
256
- resolved_allocation(@env_hash_key, entity)
257
- end
258
- end
259
-
260
- def eligible?(entity)
261
- if @flag_type == 'uncategorized' or !entity then
262
- nil
263
- else
264
- resolved_allocation(@env_hash_key, entity)
265
- end
266
- end
267
-
268
- def enabled?(entity)
269
- if @flag_type == 'uncategorized' or !entity then
270
- nil
271
- else
272
- resolved_allocation(@env_hash_key, entity)
273
- end
274
- end
275
-
276
- def resolved_allocation(env_hash_key, entity)
277
- resolve_allocations(
278
- allocation(@env_hash_key, entity),
279
- allocation(@env_hash_key, (entity.group if defined? entity.group))
280
- )
281
- end
282
-
283
- def allocation(env_hash_key, entity)
284
- off_allocation = {
285
- treatment: @treatments.find {|treatment_id, treatment| treatment.is_off_treatment}[1],
286
- eligible: false
287
- }
288
-
289
- if !entity.is_a?(BaseEntity) then
290
- return off_allocation
291
- end
292
-
293
- if @flag_status == 'archived' then
294
- STDERR.puts 'The flag has been archived'
295
- return off_allocation
296
- end
297
-
298
- if @is_paused then
299
- return off_allocation
300
- end
301
-
302
- treatment = @treatments[@overrides["#{entity.type}_#{entity.id}"]&.treatment_id]
303
- if treatment then
304
- return {
305
- treatment: treatment,
306
- eligible: !treatment.is_off_treatment,
307
- from_override: true
308
- }
309
- end
310
-
311
- use_universes = @flag_type == 'experiment'
312
-
313
- is_eligible = false
314
- @populations.each do |population|
315
- eligible, treatment = population.gate_values(
316
- entity,
317
- env_hash_key,
318
- self,
319
- use_universes
320
- ).values_at(:eligible, :treatment)
321
-
322
- if treatment != nil then
323
- return {
324
- treatment: treatment,
325
- eligible: eligible
326
- }
327
- end
328
-
329
- is_eligible ||= eligible
330
- end
331
-
332
- {
333
- treatment: off_allocation[:treatment],
334
- eligible: is_eligible
335
- }
336
- end
337
-
338
- def resolve_allocations(individual_alloc, group_alloc)
339
- if individual_alloc[:from_override] then
340
- individual_alloc
341
- elsif group_alloc[:from_override] then
342
- group_alloc
343
- elsif not individual_alloc[:treatment].is_off_treatment then
344
- individual_alloc
345
- elsif not group_alloc[:treatment].is_off_treatment then
346
- group_alloc
347
- else
348
- individual_alloc
349
- end
350
- end
351
- end
352
-
353
- class Override < GatingInfoModel
354
- SCHEMA = Dry::Validation.Schema do
355
- required(:@treatment_id) { str? }
356
- required(:@entity_id) { str? }
357
- required(:@entity_type) { str? }
358
- end
359
-
360
- attr_reader :treatment_id
361
- attr_reader :entity_id
362
- attr_reader :entity_type
363
-
364
- def initialize(json)
365
- @treatment_id = json['treatment_id']
366
- @entity_id = json['entity_id']
367
- @entity_type = json['entity_type']
368
-
369
- validate
370
- end
371
- end
372
-
373
- class Population < GatingInfoModel
374
- SCHEMA = Dry::Validation.Schema do
375
- required(:@rules) { array? }
376
- required(:@universes) { array? }
377
- required(:@percentage) { gteq?(0) & lteq?(1) }
378
- required(:@hash_key) { str? }
379
- required(:@entity_type) { str? }
380
- end
381
-
382
- attr_reader :rules
383
- attr_reader :universes
384
- attr_reader :percentage
385
- attr_reader :hash_key
386
- attr_reader :entity_type
387
-
388
- def initialize(json)
389
- @rules = (json['rules'] || []).map {|rule| Rule.new(rule)}
390
- @universes = (json['universes'] || []).map {|universe| Hash[universe.map {|split| [split['treatment_id'], Split.new(split)]}]}
391
- @percentage = json['percentage']
392
- @hash_key = json['hash_key']
393
- @entity_type = json['entity_type']
394
-
395
- validate
396
- end
397
-
398
- def gate_values(entity, env_hash_key, flag, sticky)
399
- if @entity_type != entity.type
400
- return {eligible: false}
401
- end
402
-
403
- matches = @rules.all? {|rule| rule.match(entity)}
404
- if matches then
405
- samplingHashKey = "SAMPLING:control_#{flag.hash_key}:env_#{env_hash_key}:rule_set_#{@hash_key}:client_object_#{entity.type}_#{entity.id}"
406
- hashedPercentage = Population.getHashedPercentage(samplingHashKey)
407
- if hashedPercentage <= @percentage and @percentage > 0 then
408
-
409
- allocationHashKey = "DISTRIBUTION:control_#{flag.hash_key}:env_#{env_hash_key}:client_object_#{entity.type}_#{entity.id}"
410
- allocationHashedPercentage = Population.getHashedPercentage(allocationHashKey)
411
-
412
- splits = if sticky then
413
- @universes[[(hashedPercentage * 100).floor - 1, 0].max]
414
- else
415
- flag.splits
416
- end
417
-
418
- sum = 0
419
- {
420
- eligible: true,
421
- treatment: flag.treatments.find {|treatment_id, treatment|
422
- if treatment.is_off_treatment then
423
- false
424
- else
425
- sum = (sum + (splits[treatment.treatment_id]&.percentage || 0)).round(3)
426
- allocationHashedPercentage <= sum
427
- end
428
- }[1]
429
- }
430
- else
431
- {eligible: true}
432
- end
433
- else
434
- {eligible: false}
435
- end
436
- end
437
-
438
- def self.getHashedPercentage(s)
439
- Digest::MD5.hexdigest(s).to_i(16).to_f / 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
440
- end
441
- end
442
-
443
- module RuleType
444
- STRING = 'string'
445
- INT = 'int'
446
- FLOAT = 'float'
447
- BOOLEAN = 'boolean'
448
- DATE = 'date'
449
- DATETIME = 'datetime'
450
- end
451
-
452
- module RuleOperator
453
- IS = 'is'
454
- IS_NOT = 'is_not'
455
- IN = 'in'
456
- NOT_IN = 'not_in'
457
- LT = 'lt'
458
- LTE = 'lte'
459
- GT = 'gt'
460
- GTE = 'gte'
461
- FROM = 'from'
462
- UNTIL = 'until'
463
- AFTER = 'after'
464
- BEFORE = 'before'
465
- end
466
-
467
- class Rule < GatingInfoModel
468
- SCHEMA = Dry::Validation.Schema do
469
- required(:@operator) { included_in? RuleOperator.constants(false).map(&RuleOperator.method(:const_get)) }
470
- required(:@attribute_type) { included_in? RuleType.constants(false).map(&RuleType.method(:const_get)) }
471
- required(:@attribute_name) { str? }
472
- optional(:@value_list) { array? { each { str? | bool? | type?(Numeric) } } }
473
- optional(:@value) { str? | bool? | type?(Numeric) }
474
- end
475
-
476
- attr_reader :operator
477
- attr_reader :attribute_type
478
- attr_reader :attribute_name
479
- attr_reader :value_list
480
- attr_reader :value
481
-
482
- def initialize(json)
483
- @operator = json['operator']
484
- @attribute_type = json['attribute_type']
485
- @attribute_name = json['attribute_name']
486
- @value_list = json['value_list']
487
- @value = json['value']
488
-
489
- validate
490
- end
491
-
492
- def self.categorize_value_type(value)
493
- case value
494
- when true
495
- 'boolean'
496
- when false
497
- 'boolean'
498
- when Float
499
- 'float'
500
- when Integer
501
- 'int'
502
- when String
503
- begin
504
- date = DateTime.parse(value)
505
- if date.hour == 0 && date.minute == 0 && date.second == 0 && date.second_fraction == 0 then
506
- 'date'
507
- else
508
- 'datetime'
509
- end
510
- rescue ArgumentError
511
- 'string'
512
- end
513
- else
514
- STDERR.puts 'Unexpected attribute value type encountered'
515
- nil
516
- end
517
- end
518
-
519
- def match(entity)
520
- begin
521
- if not entity.attributes.key?(@attribute_name) then
522
- return false
523
- end
524
-
525
- value = entity.attributes[@attribute_name]
526
- attribute_type = Rule.categorize_value_type(value)
527
- number_types = [RuleType::INT, RuleType::FLOAT].sort
528
-
529
- if attribute_type != @attribute_type &&
530
- [attribute_type, @attribute_type].sort != number_types then
531
- return false
532
- end
533
-
534
- if attribute_type == RuleType::STRING then
535
- if @operator == RuleOperator::IS then
536
- value == @value
537
- elsif @operator == RuleOperator::IS_NOT then
538
- value != @value
539
- elsif @operator == RuleOperator::IN then
540
- @value_list.include?(value)
541
- elsif @operator == RuleOperator::NOT_IN then
542
- not @value_list.include?(value)
543
- else
544
- STDERR.puts 'Invalid rule operator encountered'
545
- false
546
- end
547
- elsif number_types.include?(attribute_type) then
548
- if @operator == RuleOperator::IS then
549
- value == @value
550
- elsif @operator == RuleOperator::IS_NOT then
551
- value != @value
552
- elsif @operator == RuleOperator::IN then
553
- @value_list.include?(value)
554
- elsif @operator == RuleOperator::NOT_IN then
555
- not @value_list.include?(value)
556
- elsif @operator == RuleOperator::LT then
557
- value < @value
558
- elsif @operator == RuleOperator::LTE then
559
- value <= @value
560
- elsif @operator == RuleOperator::GT then
561
- value > @value
562
- elsif @operator == RuleOperator::GTE then
563
- value >= @value
564
- else
565
- STDERR.puts 'Invalid rule operator encountered'
566
- false
567
- end
568
- elsif attribute_type == RuleType::BOOLEAN then
569
- if @operator == RuleOperator::IS then
570
- value == @value
571
- elsif @operator == RuleOperator::IS_NOT then
572
- value != @value
573
- else
574
- STDERR.puts 'Invalid rule operator encountered'
575
- false
576
- end
577
- elsif attribute_type == RuleType::DATE || attribute_type == RuleType::DATETIME then
578
- target_time = @value && DateTime.parse(@value)
579
- target_time_list = @value_list&.map {|tv| DateTime.parse(tv)}
580
- value_time = DateTime.parse(value)
581
-
582
- if @operator == RuleOperator::IS then
583
- value_time == target_time
584
- elsif @operator == RuleOperator::IS_NOT then
585
- value_time != target_time
586
- elsif @operator == RuleOperator::IN then
587
- target_time_list.include?(value_time)
588
- elsif @operator == RuleOperator::NOT_IN then
589
- not target_time_list.include?(value_time)
590
- elsif @operator == RuleOperator::FROM then
591
- value_time >= target_time
592
- elsif @operator == RuleOperator::UNTIL then
593
- value_time <= target_time
594
- elsif @operator == RuleOperator::AFTER then
595
- value_time > target_time
596
- elsif @operator == RuleOperator::BEFORE then
597
- value_time < target_time
598
- else
599
- STDERR.puts 'Invalid rule operator encountered'
600
- false
601
- end
602
- else
603
- STDERR.puts 'Invalid attribute type encountered'
604
- false
605
- end
606
- rescue Exception => e
607
- STDERR.puts "Traceback:"
608
- STDERR.puts e.backtrace
609
- STDERR.puts "#{e.class} (#{e.message})"
610
- false
611
- end
612
- end
613
- end
614
-
615
- class Split < GatingInfoModel
616
- SCHEMA = Dry::Validation.Schema do
617
- required(:@treatment_id) { str? }
618
- required(:@percentage) { gteq?(0) & lteq?(1) }
619
- end
620
-
621
- attr_reader :treatment_id
622
- attr_reader :percentage
623
-
624
- def initialize(json)
625
- @treatment_id = json['treatment_id']
626
- @percentage = json['percentage']
627
-
628
- validate
629
- end
630
- end
631
-
632
- class Treatment < GatingInfoModel
633
- SCHEMA = Dry::Validation.Schema do
634
- required(:@treatment_id) { str? }
635
- required(:@is_ghost) { bool? }
636
- required(:@codename) { str? }
637
- required(:@is_off_treatment) { bool? }
638
- optional(:@payload) { str? | type?(Numeric) | bool? | array? | type?(Hash) }
639
- end
640
-
641
- attr_reader :treatment_id
642
- attr_reader :is_ghost
643
- attr_reader :codename
644
- attr_reader :is_off_treatment
645
- attr_reader :payload
646
-
647
- def initialize(json)
648
- @treatment_id = json['treatment_id']
649
- @is_ghost = json['is_ghost']
650
- @codename = json['codename']
651
- @is_off_treatment = json['is_off_treatment']
652
- @payload = json['payload']
653
-
654
- validate
655
- end
656
- end
657
- end