flagger 2.0.8 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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