flagger 2.0.9 → 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,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,656 +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
- hashedPercentage = Population.getHashedPercentage(samplingHashKey)
406
- if hashedPercentage <= @percentage and @percentage > 0 then
407
-
408
- allocationHashKey = "DISTRIBUTION:control_#{flag.hash_key}:env_#{env_hash_key}:client_object_#{entity.type}_#{entity.id}"
409
- allocationHashedPercentage = Population.getHashedPercentage(allocationHashKey)
410
-
411
- splits = if sticky then
412
- @universes[[(hashedPercentage * 100).floor - 1, 0].max]
413
- else
414
- flag.splits
415
- end
416
-
417
- sum = 0
418
- {
419
- eligible: true,
420
- treatment: flag.treatments.find {|treatment_id, treatment|
421
- if treatment.is_off_treatment then
422
- false
423
- else
424
- sum = (sum + (splits[treatment.treatment_id]&.percentage || 0)).round(3)
425
- allocationHashedPercentage <= sum
426
- end
427
- }[1]
428
- }
429
- else
430
- {eligible: true}
431
- end
432
- else
433
- {eligible: false}
434
- end
435
- end
436
-
437
- def self.getHashedPercentage(s)
438
- Digest::MD5.hexdigest(s).to_i(16).to_f / 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
439
- end
440
- end
441
-
442
- module RuleType
443
- STRING = 'string'
444
- INT = 'int'
445
- FLOAT = 'float'
446
- BOOLEAN = 'boolean'
447
- DATE = 'date'
448
- DATETIME = 'datetime'
449
- end
450
-
451
- module RuleOperator
452
- IS = 'is'
453
- IS_NOT = 'is_not'
454
- IN = 'in'
455
- NOT_IN = 'not_in'
456
- LT = 'lt'
457
- LTE = 'lte'
458
- GT = 'gt'
459
- GTE = 'gte'
460
- FROM = 'from'
461
- UNTIL = 'until'
462
- AFTER = 'after'
463
- BEFORE = 'before'
464
- end
465
-
466
- class Rule < GatingInfoModel
467
- SCHEMA = Dry::Validation.Schema do
468
- required(:@operator) { included_in? RuleOperator.constants(false).map(&RuleOperator.method(:const_get)) }
469
- required(:@attribute_type) { included_in? RuleType.constants(false).map(&RuleType.method(:const_get)) }
470
- required(:@attribute_name) { str? }
471
- optional(:@value_list) { array? { each { str? | bool? | type?(Numeric) } } }
472
- optional(:@value) { str? | bool? | type?(Numeric) }
473
- end
474
-
475
- attr_reader :operator
476
- attr_reader :attribute_type
477
- attr_reader :attribute_name
478
- attr_reader :value_list
479
- attr_reader :value
480
-
481
- def initialize(json)
482
- @operator = json['operator']
483
- @attribute_type = json['attribute_type']
484
- @attribute_name = json['attribute_name']
485
- @value_list = json['value_list']
486
- @value = json['value']
487
-
488
- validate
489
- end
490
-
491
- def self.categorize_value_type(value)
492
- case value
493
- when true
494
- 'boolean'
495
- when false
496
- 'boolean'
497
- when Float
498
- 'float'
499
- when Integer
500
- 'int'
501
- when String
502
- begin
503
- date = DateTime.parse(value)
504
- if date.hour == 0 && date.minute == 0 && date.second == 0 && date.second_fraction == 0 then
505
- 'date'
506
- else
507
- 'datetime'
508
- end
509
- rescue ArgumentError
510
- 'string'
511
- end
512
- else
513
- STDERR.puts 'Unexpected attribute value type encountered'
514
- nil
515
- end
516
- end
517
-
518
- def match(entity)
519
- begin
520
- if not entity.attributes.key?(@attribute_name) then
521
- return false
522
- end
523
-
524
- value = entity.attributes[@attribute_name]
525
- attribute_type = Rule.categorize_value_type(value)
526
- number_types = [RuleType::INT, RuleType::FLOAT].sort
527
-
528
- if attribute_type != @attribute_type &&
529
- [attribute_type, @attribute_type].sort != number_types then
530
- return false
531
- end
532
-
533
- if attribute_type == RuleType::STRING then
534
- if @operator == RuleOperator::IS then
535
- value == @value
536
- elsif @operator == RuleOperator::IS_NOT then
537
- value != @value
538
- elsif @operator == RuleOperator::IN then
539
- @value_list.include?(value)
540
- elsif @operator == RuleOperator::NOT_IN then
541
- not @value_list.include?(value)
542
- else
543
- STDERR.puts 'Invalid rule operator encountered'
544
- false
545
- end
546
- elsif number_types.include?(attribute_type) then
547
- if @operator == RuleOperator::IS then
548
- value == @value
549
- elsif @operator == RuleOperator::IS_NOT then
550
- value != @value
551
- elsif @operator == RuleOperator::IN then
552
- @value_list.include?(value)
553
- elsif @operator == RuleOperator::NOT_IN then
554
- not @value_list.include?(value)
555
- elsif @operator == RuleOperator::LT then
556
- value < @value
557
- elsif @operator == RuleOperator::LTE then
558
- value <= @value
559
- elsif @operator == RuleOperator::GT then
560
- value > @value
561
- elsif @operator == RuleOperator::GTE then
562
- value >= @value
563
- else
564
- STDERR.puts 'Invalid rule operator encountered'
565
- false
566
- end
567
- elsif attribute_type == RuleType::BOOLEAN then
568
- if @operator == RuleOperator::IS then
569
- value == @value
570
- elsif @operator == RuleOperator::IS_NOT then
571
- value != @value
572
- else
573
- STDERR.puts 'Invalid rule operator encountered'
574
- false
575
- end
576
- elsif attribute_type == RuleType::DATE || attribute_type == RuleType::DATETIME then
577
- target_time = @value && DateTime.parse(@value)
578
- target_time_list = @value_list&.map {|tv| DateTime.parse(tv)}
579
- value_time = DateTime.parse(value)
580
-
581
- if @operator == RuleOperator::IS then
582
- value_time == target_time
583
- elsif @operator == RuleOperator::IS_NOT then
584
- value_time != target_time
585
- elsif @operator == RuleOperator::IN then
586
- target_time_list.include?(value_time)
587
- elsif @operator == RuleOperator::NOT_IN then
588
- not target_time_list.include?(value_time)
589
- elsif @operator == RuleOperator::FROM then
590
- value_time >= target_time
591
- elsif @operator == RuleOperator::UNTIL then
592
- value_time <= target_time
593
- elsif @operator == RuleOperator::AFTER then
594
- value_time > target_time
595
- elsif @operator == RuleOperator::BEFORE then
596
- value_time < target_time
597
- else
598
- STDERR.puts 'Invalid rule operator encountered'
599
- false
600
- end
601
- else
602
- STDERR.puts 'Invalid attribute type encountered'
603
- false
604
- end
605
- rescue Exception => e
606
- STDERR.puts "Traceback:"
607
- STDERR.puts e.backtrace
608
- STDERR.puts "#{e.class} (#{e.message})"
609
- false
610
- end
611
- end
612
- end
613
-
614
- class Split < GatingInfoModel
615
- SCHEMA = Dry::Validation.Schema do
616
- required(:@treatment_id) { str? }
617
- required(:@percentage) { gteq?(0) & lteq?(1) }
618
- end
619
-
620
- attr_reader :treatment_id
621
- attr_reader :percentage
622
-
623
- def initialize(json)
624
- @treatment_id = json['treatment_id']
625
- @percentage = json['percentage']
626
-
627
- validate
628
- end
629
- end
630
-
631
- class Treatment < GatingInfoModel
632
- SCHEMA = Dry::Validation.Schema do
633
- required(:@treatment_id) { str? }
634
- required(:@is_control) { bool? }
635
- required(:@codename) { str? }
636
- required(:@is_off_treatment) { bool? }
637
- optional(:@payload) { str? | type?(Numeric) | bool? | array? | type?(Hash) }
638
- end
639
-
640
- attr_reader :treatment_id
641
- attr_reader :is_control
642
- attr_reader :codename
643
- attr_reader :is_off_treatment
644
- attr_reader :payload
645
-
646
- def initialize(json)
647
- @treatment_id = json['treatment_id']
648
- @is_control = json['is_control']
649
- @codename = json['codename']
650
- @is_off_treatment = json['is_off_treatment']
651
- @payload = json['payload']
652
-
653
- validate
654
- end
655
- end
656
- end