flagger 2.0.9 → 3.0.1

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,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