flagger 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fd2012064f6365502851698b36a4aca47eb948730956ae20b3ab6fd7431456e0
4
+ data.tar.gz: a7d18728dc84c3d3f17cab164ce8893fe9857b6e832a27ca0b7cee947eed495a
5
+ SHA512:
6
+ metadata.gz: d63e4863cf230e9aabc956e06a8b0a88228bda6234230c2ce4766f2a4e71efc5b7d429608693791c8fdb62bcc050a625b311669b90c952ef818590bcb21e68ed
7
+ data.tar.gz: 2b93f074f5318ab8feffb2ed9bcd9726756c42d6a4d5a059364217407f531a1a84152ad13ba28ce085b220a984bd57a40c53a199db95f2f17144eb623dbacc6a
@@ -0,0 +1,287 @@
1
+ require 'eventmachine'
2
+ require 'faraday'
3
+ require 'faye/websocket'
4
+ require 'json'
5
+ require 'lru_redux'
6
+ require 'set'
7
+ require 'flagger/models'
8
+ require 'flagger/stat'
9
+ require 'flagger/version'
10
+
11
+ module FlaggerEnvironments
12
+ SERVER_URL = 'https://api.airshiphq.com'
13
+ IDENTIFY_ENDPOINT = "#{SERVER_URL}/v2/identify"
14
+ GATING_INFO_ENDPOINT = "#{SERVER_URL}/v2/gating-info"
15
+
16
+ WEBSOCKET_URL = 'wss://ws.airshiphq.com'
17
+ WEBSOCKET_GATING_INFO_ENDPOINT = "#{WEBSOCKET_URL}/v2/ws-events"
18
+
19
+ CLOUDFRONT_URL = 'https://backup-api.airshiphq.com'
20
+ CLOUDFRONT_GATING_INFO_ENDPOINT = "#{CLOUDFRONT_URL}/v2/gating-info"
21
+
22
+ class CloudDelegate
23
+ attr_accessor :gating_info
24
+ attr_accessor :env_key
25
+ attr_accessor :request_timeout
26
+
27
+ def initialize(env_key, request_timeout)
28
+ @entity_lru_hashes = LruRedux::Cache.new(500)
29
+ @entities = []
30
+ @stats = []
31
+ @exposures = []
32
+ @flags = Set[]
33
+ @ingested_flags = Set[]
34
+ @env_key = env_key
35
+ @request_timeout = request_timeout
36
+
37
+ @ingestion_max_items = 500
38
+ @ingestion_interval = 30
39
+
40
+ begin
41
+ get_gating_info("#{GATING_INFO_ENDPOINT}/#{@env_key}", 'duration__gating_info')
42
+ rescue Exception => e
43
+ STDERR.puts 'Failed to retrieve initial gating info from API', e.backtrace
44
+ end
45
+
46
+ begin
47
+ get_gating_info("#{CLOUDFRONT_GATING_INFO_ENDPOINT}/#{@env_key}", 'duration__cloudfront_gating_info') unless !@gating_info.nil?
48
+ rescue Exception => e
49
+ STDERR.puts 'Failed to retrieve initial gating info from CloudFront backup', e.backtrace
50
+ end
51
+
52
+ if EM.reactor_running? then
53
+ em_subscribe_updates
54
+ em_ingestion_timer
55
+ else
56
+ Thread.new {
57
+ EM.run {
58
+ em_subscribe_updates
59
+ em_ingestion_timer
60
+ }
61
+ }
62
+ end
63
+ end
64
+
65
+ def get_gating_info(url, stat_name)
66
+ stat = FlaggerUtils::Stat.new(stat_name)
67
+ stat.start
68
+ conn = Faraday.new(url: url)
69
+ response = conn.get do |req|
70
+ req.options.timeout = @request_timeout
71
+ end
72
+ if response.status == 200
73
+ @gating_info = FlaggerModels::GatingInfo.new(JSON.parse(response.body))
74
+ update_sdk_vars(@gating_info)
75
+ stat.stop
76
+ save_stat(stat)
77
+ else
78
+ raise "Gating info request failed with HTTP code #{response.code}"
79
+ end
80
+ end
81
+
82
+ def em_subscribe_updates()
83
+ @ws_unsubbed = false
84
+ @ws = Faye::WebSocket::Client.new("#{WEBSOCKET_GATING_INFO_ENDPOINT}?envkey=#{@env_key}")
85
+
86
+ @ws.on :message do |event|
87
+ begin
88
+ @gating_info = FlaggerModels::GatingInfo.new(JSON.parse(event.data))
89
+ update_sdk_vars(@gating_info)
90
+ rescue Exception => e
91
+ STDERR.puts 'Failed to update gating information', e
92
+ end
93
+ end
94
+
95
+ @ws.on :close do |event|
96
+ STDERR.puts ['Connection closed to Airship update server', event.code, event.reason] unless @ws_unsubbed
97
+ EventMachine::Timer.new(5) do
98
+ em_subscribe_updates
99
+ end unless @ws_unsubbed
100
+ end
101
+ end
102
+
103
+ def unsubscribe_updates()
104
+ @ws_unsubbed = true
105
+ @ws.close()
106
+ end
107
+
108
+ def update_sdk_vars(gating_info)
109
+ if gating_info&.sdk_info&.ingestion_max_items then
110
+ @ingestion_max_items = gating_info.sdk_info.ingestion_max_items
111
+ end
112
+ if gating_info&.sdk_info&.ingestion_interval then
113
+ @ingestion_interval = gating_info.sdk_info.ingestion_interval
114
+ end
115
+ end
116
+
117
+ def maybe_ingest(should_ingest = false)
118
+ should_ingest |= (
119
+ @entities.length > @ingestion_max_items ||
120
+ @stats.length > @ingestion_max_items ||
121
+ @exposures.length > @ingestion_max_items ||
122
+ @flags.length > 0
123
+ )
124
+
125
+ should_ingest &= (
126
+ @entities.length > 0 ||
127
+ @stats.length > 0 ||
128
+ @exposures.length > 0 ||
129
+ @flags.length > 0
130
+ )
131
+
132
+ if should_ingest then
133
+ begin
134
+ entities = @entities
135
+ stats = @stats
136
+ exposures = @exposures
137
+ flags = @flags.to_a
138
+ @entities = []
139
+ @stats = []
140
+ @exposures = []
141
+ @ingested_flags.merge(@flags)
142
+ @flags = Set[]
143
+
144
+ conn = Faraday.new(url: "#{IDENTIFY_ENDPOINT}/#{@env_key}")
145
+ response = conn.post do |req|
146
+ req.options.timeout = @request_timeout
147
+ req.headers['Content-Type'] = 'application/json'
148
+ req.body = JSON.generate(
149
+ {
150
+ 'sdk_info' => {
151
+ 'name' => 'ruby',
152
+ 'version' => FlaggerUtils::VERSION
153
+ },
154
+ 'objects' => entities,
155
+ 'stats' => stats,
156
+ 'exposures' => exposures,
157
+ 'flags' => flags
158
+ })
159
+ end
160
+ if response.status != 200 then
161
+ STDERR.puts 'Failed to upload entities', response.status
162
+ nil
163
+ end
164
+ rescue Exception => e
165
+ STDERR.puts 'Failed to upload entities', e
166
+ nil
167
+ end
168
+ end
169
+ end
170
+
171
+ def em_ingestion_timer
172
+ seconds = 0
173
+ timer = EventMachine::PeriodicTimer.new(1) do
174
+ timer.cancel unless !@cleaning
175
+
176
+ seconds = (seconds + 1) % @ingestion_interval
177
+ if seconds == 0 then
178
+ EM.defer(proc {
179
+ maybe_ingest(true)
180
+ })
181
+ end
182
+ end
183
+ end
184
+
185
+ def cleanup()
186
+ @cleaning = true
187
+ unsubscribe_updates
188
+ maybe_ingest(true)
189
+ end
190
+
191
+ def save_entity(entity)
192
+ if @entity_lru_hashes[entity.id] != entity.hash then
193
+ @entity_lru_hashes[entity.id] = entity.hash
194
+ @entities.push(entity.attrs)
195
+ maybe_ingest()
196
+ end
197
+ end
198
+
199
+ def save_stat(stat)
200
+ @stats.push(stat)
201
+ @stats = FlaggerUtils::Stat.compact(@stats)
202
+ maybe_ingest()
203
+ end
204
+
205
+ def save_exposure(exposure)
206
+ @exposures.push(exposure)
207
+ maybe_ingest()
208
+ end
209
+
210
+ def save_flag(flag)
211
+ @flags.add(flag)
212
+ maybe_ingest()
213
+ end
214
+
215
+ def identify_entity(entity_json)
216
+ if !entity_json.is_a?(Hash) then
217
+ STDERR.puts 'Entity must be a hash'
218
+ nil
219
+ else
220
+ entity = if entity_json['is_group'] then
221
+ FlaggerModels::GroupEntity.new(entity_json)
222
+ else
223
+ FlaggerModels::SingleEntity.new(entity_json)
224
+ end
225
+
226
+ if !entity.valid? then
227
+ STDERR.puts "Entity validation errors: #{entity.validation_errors}"
228
+ nil
229
+ else
230
+ save_entity(entity)
231
+
232
+ entity
233
+ end
234
+ end
235
+ end
236
+
237
+ {
238
+ 'treatment' => 'duration__get_treatment',
239
+ 'payload' => 'duration__get_payload',
240
+ 'eligible?' => 'duration__is_eligible',
241
+ 'enabled?' => 'duration__is_enabled',
242
+ }.each do |name, stat_name|
243
+ define_method(name.to_sym) do |flagger_flag, entity_json|
244
+ stat = FlaggerUtils::Stat.new(stat_name)
245
+ stat.start()
246
+
247
+ entity = identify_entity(entity_json)
248
+ flag = @gating_info&.flag(flagger_flag.flag_name)
249
+ if flag.nil? && !@ingested_flags.include?(flagger_flag.flag_name) then
250
+ save_flag(flagger_flag.flag_name)
251
+ end
252
+ return_value = flag&.public_send(name, entity)
253
+
254
+ if name == 'treatment' and !return_value.nil? then
255
+ exposure = {
256
+ type: entity.type,
257
+ id: entity.id,
258
+ treatment_id: return_value.treatment_id,
259
+ treatment: return_value.codename
260
+ }
261
+
262
+ return_value = return_value.codename
263
+
264
+ save_exposure(exposure)
265
+ end
266
+
267
+ stat.stop()
268
+ save_stat(stat)
269
+
270
+ return_value
271
+ end
272
+ end
273
+
274
+ def publish(entities)
275
+ if !entities.is_a?(Array) then
276
+ STDERR.puts 'The "publish" method takes an array of objects (aka entities).'
277
+ return nil
278
+ end
279
+
280
+ entities.each do |entity|
281
+ identify_entity(entity)
282
+ end
283
+
284
+ maybe_ingest(true)
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,643 @@
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
+ end
156
+
157
+ attr_reader :ingestion_interval
158
+ attr_reader :ingestion_max_items
159
+
160
+ def initialize(json)
161
+ @ingestion_interval = json['SDK_INGESTION_INTERVAL']
162
+ @ingestion_max_items = json['SDK_INGESTION_MAX_ITEMS']
163
+
164
+ validate
165
+ end
166
+ end
167
+
168
+ class Env < GatingInfoModel
169
+ SCHEMA = Dry::Validation.Schema do
170
+ required(:@env_key) { str? }
171
+ required(:@hash_key) { str? }
172
+ end
173
+
174
+ attr_reader :env_key
175
+ attr_reader :hash_key
176
+
177
+ def initialize(json)
178
+ @env_key = json['env_key']
179
+ @hash_key = json['hash_key']
180
+
181
+ validate
182
+ end
183
+ end
184
+
185
+ class Flag < GatingInfoModel
186
+ SCHEMA = Dry::Validation.Schema do
187
+ required(:@codename) { str? }
188
+ required(:@flag_status) { included_in? ['operational', 'archived'] }
189
+ required(:@flag_type) { included_in? ['basic', 'experiment', 'uncategorized'] }
190
+ required(:@hash_key) { str? }
191
+ optional(:@is_paused) { bool? }
192
+ required(:@is_web_accessible) { bool? }
193
+ required(:@overrides) { hash? }
194
+ required(:@populations) { array? }
195
+ required(:@splits) { hash? }
196
+ required(:@treatments) { hash? }
197
+
198
+ required(:@env_hash_key) { str? }
199
+ end
200
+
201
+ attr_reader :codename
202
+ attr_reader :flag_status
203
+ attr_reader :flag_type
204
+ attr_reader :hash_key
205
+ attr_reader :is_paused
206
+ attr_reader :is_web_accessible
207
+ attr_reader :overrides
208
+ attr_reader :populations
209
+ attr_reader :splits
210
+ attr_reader :treatments
211
+
212
+ attr_reader :env_hash_key
213
+
214
+ def initialize(json, env_hash_key)
215
+ @codename = json['codename']
216
+ @flag_status = json['flag_status']
217
+ @flag_type = json['flag_type']
218
+ @hash_key = json['hash_key']
219
+ @is_paused = json['is_paused']
220
+ @is_web_accessible = json['is_web_accessible']
221
+ @overrides = Hash[(json['overrides'] || []).map {|override| ["#{override['entity_type']}_#{override['entity_id']}", Override.new(override)]}]
222
+ @populations = (json['populations'] || []).map {|population| Population.new(population)}
223
+ @splits = Hash[(json['splits'] || []).map {|split| [split['treatment_id'], Split.new(split)]}]
224
+ @treatments = Hash[(json['treatments'] || []).map {|treatment| [treatment['treatment_id'], Treatment.new(treatment)]}]
225
+
226
+ @env_hash_key = env_hash_key
227
+
228
+ validate
229
+ end
230
+
231
+ def treatment(entity)
232
+ if @flag_type == 'uncategorized' or !entity then
233
+ nil
234
+ else
235
+ resolved_allocation(@env_hash_key, entity)[:treatment]
236
+ end
237
+ end
238
+
239
+ def payload(entity)
240
+ if @flag_type == 'uncategorized' or !entity then
241
+ nil
242
+ else
243
+ resolved_allocation(@env_hash_key, entity)[:treatment].payload
244
+ end
245
+ end
246
+
247
+ def eligible?(entity)
248
+ if @flag_type == 'uncategorized' or !entity then
249
+ false
250
+ else
251
+ resolved_allocation(@env_hash_key, entity)[:eligible]
252
+ end
253
+ end
254
+
255
+ def enabled?(entity)
256
+ if @flag_type == 'uncategorized' or !entity then
257
+ false
258
+ else
259
+ not resolved_allocation(@env_hash_key, entity)[:treatment].is_off_treatment
260
+ end
261
+ end
262
+
263
+ def resolved_allocation(env_hash_key, entity)
264
+ resolve_allocations(
265
+ allocation(@env_hash_key, entity),
266
+ allocation(@env_hash_key, (entity.group if defined? entity.group))
267
+ )
268
+ end
269
+
270
+ def allocation(env_hash_key, entity)
271
+ off_allocation = {
272
+ treatment: @treatments.find {|treatment_id, treatment| treatment.is_off_treatment}[1],
273
+ eligible: false
274
+ }
275
+
276
+ if !entity.is_a?(BaseEntity) then
277
+ return off_allocation
278
+ end
279
+
280
+ if @flag_status == 'archived' then
281
+ STDERR.puts 'The flag has been archived'
282
+ return off_allocation
283
+ end
284
+
285
+ if @is_paused then
286
+ return off_allocation
287
+ end
288
+
289
+ treatment = @treatments[@overrides["#{entity.type}_#{entity.id}"]&.treatment_id]
290
+ if treatment then
291
+ return {
292
+ treatment: treatment,
293
+ eligible: !treatment.is_off_treatment,
294
+ from_override: true
295
+ }
296
+ end
297
+
298
+ use_universes = @flag_type == 'experiment'
299
+
300
+ is_eligible = false
301
+ @populations.each do |population|
302
+ eligible, treatment = population.gate_values(
303
+ entity,
304
+ env_hash_key,
305
+ self,
306
+ use_universes
307
+ ).values_at(:eligible, :treatment)
308
+
309
+ if treatment != nil then
310
+ return {
311
+ treatment: treatment,
312
+ eligible: eligible
313
+ }
314
+ end
315
+
316
+ is_eligible ||= eligible
317
+ end
318
+
319
+ {
320
+ treatment: off_allocation[:treatment],
321
+ eligible: is_eligible
322
+ }
323
+ end
324
+
325
+ def resolve_allocations(individual_alloc, group_alloc)
326
+ if individual_alloc[:from_override] then
327
+ individual_alloc
328
+ elsif group_alloc[:from_override] then
329
+ group_alloc
330
+ elsif not individual_alloc[:treatment].is_off_treatment then
331
+ individual_alloc
332
+ elsif not group_alloc[:treatment].is_off_treatment then
333
+ group_alloc
334
+ else
335
+ individual_alloc
336
+ end
337
+ end
338
+ end
339
+
340
+ class Override < GatingInfoModel
341
+ SCHEMA = Dry::Validation.Schema do
342
+ required(:@treatment_id) { str? }
343
+ required(:@entity_id) { str? }
344
+ required(:@entity_type) { str? }
345
+ end
346
+
347
+ attr_reader :treatment_id
348
+ attr_reader :entity_id
349
+ attr_reader :entity_type
350
+
351
+ def initialize(json)
352
+ @treatment_id = json['treatment_id']
353
+ @entity_id = json['entity_id']
354
+ @entity_type = json['entity_type']
355
+
356
+ validate
357
+ end
358
+ end
359
+
360
+ class Population < GatingInfoModel
361
+ SCHEMA = Dry::Validation.Schema do
362
+ required(:@rules) { array? }
363
+ required(:@universes) { array? }
364
+ required(:@percentage) { gteq?(0) & lteq?(1) }
365
+ required(:@hash_key) { str? }
366
+ required(:@entity_type) { str? }
367
+ end
368
+
369
+ attr_reader :rules
370
+ attr_reader :universes
371
+ attr_reader :percentage
372
+ attr_reader :hash_key
373
+ attr_reader :entity_type
374
+
375
+ def initialize(json)
376
+ @rules = (json['rules'] || []).map {|rule| Rule.new(rule)}
377
+ @universes = (json['universes'] || []).map {|universe| Hash[universe.map {|split| [split['treatment_id'], Split.new(split)]}]}
378
+ @percentage = json['percentage']
379
+ @hash_key = json['hash_key']
380
+ @entity_type = json['entity_type']
381
+
382
+ validate
383
+ end
384
+
385
+ def gate_values(entity, env_hash_key, flag, sticky)
386
+ if @entity_type != entity.type
387
+ return {eligible: false}
388
+ end
389
+
390
+ matches = @rules.all? {|rule| rule.match(entity)}
391
+ if matches then
392
+ samplingHashKey = "SAMPLING:control_#{flag.hash_key}:env_#{env_hash_key}:rule_set_#{@hash_key}:client_object_#{entity.type}_#{entity.id}"
393
+ if Population.getHashedPercentage(samplingHashKey) <= @percentage and @percentage > 0 then
394
+
395
+ allocationHashKey = "DISTRIBUTION:control_#{flag.hash_key}:env_#{env_hash_key}:client_object_#{entity.type}_#{entity.id}"
396
+ allocationHashedPercentage = Population.getHashedPercentage(allocationHashKey)
397
+
398
+ splits = if sticky then
399
+ @universes[(@percentage * 100).floor - 1]
400
+ else
401
+ flag.splits
402
+ end
403
+
404
+ sum = 0
405
+ {
406
+ eligible: true,
407
+ treatment: flag.treatments.find {|treatment_id, treatment|
408
+ if treatment.is_off_treatment then
409
+ false
410
+ else
411
+ sum = (sum + (splits[treatment.treatment_id]&.percentage || 0)).round(3)
412
+ allocationHashedPercentage <= sum
413
+ end
414
+ }[1]
415
+ }
416
+ else
417
+ {eligible: true}
418
+ end
419
+ else
420
+ {eligible: false}
421
+ end
422
+ end
423
+
424
+ def self.getHashedPercentage(s)
425
+ Digest::MD5.hexdigest(s).to_i(16).to_f / 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
426
+ end
427
+ end
428
+
429
+ module RuleType
430
+ STRING = 'string'
431
+ INT = 'int'
432
+ FLOAT = 'float'
433
+ BOOLEAN = 'boolean'
434
+ DATE = 'date'
435
+ DATETIME = 'datetime'
436
+ end
437
+
438
+ module RuleOperator
439
+ IS = 'is'
440
+ IS_NOT = 'is_not'
441
+ IN = 'in'
442
+ NOT_IN = 'not_in'
443
+ LT = 'lt'
444
+ LTE = 'lte'
445
+ GT = 'gt'
446
+ GTE = 'gte'
447
+ FROM = 'from'
448
+ UNTIL = 'until'
449
+ AFTER = 'after'
450
+ BEFORE = 'before'
451
+ end
452
+
453
+ class Rule < GatingInfoModel
454
+ SCHEMA = Dry::Validation.Schema do
455
+ required(:@operator) { included_in? RuleOperator.constants(false).map(&RuleOperator.method(:const_get)) }
456
+ required(:@attribute_type) { included_in? RuleType.constants(false).map(&RuleType.method(:const_get)) }
457
+ required(:@attribute_name) { str? }
458
+ optional(:@value_list) { array? { each { str? | bool? | type?(Numeric) } } }
459
+ optional(:@value) { str? | bool? | type?(Numeric) }
460
+ end
461
+
462
+ attr_reader :operator
463
+ attr_reader :attribute_type
464
+ attr_reader :attribute_name
465
+ attr_reader :value_list
466
+ attr_reader :value
467
+
468
+ def initialize(json)
469
+ @operator = json['operator']
470
+ @attribute_type = json['attribute_type']
471
+ @attribute_name = json['attribute_name']
472
+ @value_list = json['value_list']
473
+ @value = json['value']
474
+
475
+ validate
476
+ end
477
+
478
+ def self.categorize_value_type(value)
479
+ case value
480
+ when true
481
+ 'boolean'
482
+ when false
483
+ 'boolean'
484
+ when Float
485
+ 'float'
486
+ when Integer
487
+ 'int'
488
+ when String
489
+ begin
490
+ date = DateTime.parse(value)
491
+ if date.hour == 0 && date.minute == 0 && date.second == 0 && date.second_fraction == 0 then
492
+ 'date'
493
+ else
494
+ 'datetime'
495
+ end
496
+ rescue ArgumentError
497
+ 'string'
498
+ end
499
+ else
500
+ STDERR.puts 'Unexpected attribute value type encountered'
501
+ nil
502
+ end
503
+ end
504
+
505
+ def match(entity)
506
+ begin
507
+ if not entity.attributes.key?(@attribute_name) then
508
+ return false
509
+ end
510
+
511
+ value = entity.attributes[@attribute_name]
512
+ attribute_type = Rule.categorize_value_type(value)
513
+ number_types = [RuleType::INT, RuleType::FLOAT].sort
514
+
515
+ if attribute_type != @attribute_type &&
516
+ [attribute_type, @attribute_type].sort != number_types then
517
+ return false
518
+ end
519
+
520
+ if attribute_type == RuleType::STRING then
521
+ if @operator == RuleOperator::IS then
522
+ value == @value
523
+ elsif @operator == RuleOperator::IS_NOT then
524
+ value != @value
525
+ elsif @operator == RuleOperator::IN then
526
+ @value_list.include?(value)
527
+ elsif @operator == RuleOperator::NOT_IN then
528
+ not @value_list.include?(value)
529
+ else
530
+ STDERR.puts 'Invalid rule operator encountered'
531
+ false
532
+ end
533
+ elsif number_types.include?(attribute_type) 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
+ elsif @operator == RuleOperator::LT then
543
+ value < @value
544
+ elsif @operator == RuleOperator::LTE then
545
+ value <= @value
546
+ elsif @operator == RuleOperator::GT then
547
+ value > @value
548
+ elsif @operator == RuleOperator::GTE then
549
+ value >= @value
550
+ else
551
+ STDERR.puts 'Invalid rule operator encountered'
552
+ false
553
+ end
554
+ elsif attribute_type == RuleType::BOOLEAN then
555
+ if @operator == RuleOperator::IS then
556
+ value == @value
557
+ elsif @operator == RuleOperator::IS_NOT then
558
+ value != @value
559
+ else
560
+ STDERR.puts 'Invalid rule operator encountered'
561
+ false
562
+ end
563
+ elsif attribute_type == RuleType::DATE || attribute_type == RuleType::DATETIME then
564
+ target_time = @value && DateTime.parse(@value)
565
+ target_time_list = @value_list&.map {|tv| DateTime.parse(tv)}
566
+ value_time = DateTime.parse(value)
567
+
568
+ if @operator == RuleOperator::IS then
569
+ value_time == target_time
570
+ elsif @operator == RuleOperator::IS_NOT then
571
+ value_time != target_time
572
+ elsif @operator == RuleOperator::IN then
573
+ target_time_list.include?(value_time)
574
+ elsif @operator == RuleOperator::NOT_IN then
575
+ not target_time_list.include?(value_time)
576
+ elsif @operator == RuleOperator::FROM then
577
+ value_time >= target_time
578
+ elsif @operator == RuleOperator::UNTIL then
579
+ value_time <= target_time
580
+ elsif @operator == RuleOperator::AFTER then
581
+ value_time > target_time
582
+ elsif @operator == RuleOperator::BEFORE then
583
+ value_time < target_time
584
+ else
585
+ STDERR.puts 'Invalid rule operator encountered'
586
+ false
587
+ end
588
+ else
589
+ STDERR.puts 'Invalid attribute type encountered'
590
+ false
591
+ end
592
+ rescue Exception => e
593
+ STDERR.puts "Traceback:"
594
+ STDERR.puts e.backtrace
595
+ STDERR.puts "#{e.class} (#{e.message})"
596
+ false
597
+ end
598
+ end
599
+ end
600
+
601
+ class Split < GatingInfoModel
602
+ SCHEMA = Dry::Validation.Schema do
603
+ required(:@treatment_id) { str? }
604
+ required(:@percentage) { gteq?(0) & lteq?(1) }
605
+ end
606
+
607
+ attr_reader :treatment_id
608
+ attr_reader :percentage
609
+
610
+ def initialize(json)
611
+ @treatment_id = json['treatment_id']
612
+ @percentage = json['percentage']
613
+
614
+ validate
615
+ end
616
+ end
617
+
618
+ class Treatment < GatingInfoModel
619
+ SCHEMA = Dry::Validation.Schema do
620
+ required(:@treatment_id) { str? }
621
+ required(:@is_control) { bool? }
622
+ required(:@codename) { str? }
623
+ required(:@is_off_treatment) { bool? }
624
+ optional(:@payload) { str? }
625
+ end
626
+
627
+ attr_reader :treatment_id
628
+ attr_reader :is_control
629
+ attr_reader :codename
630
+ attr_reader :is_off_treatment
631
+ attr_reader :payload
632
+
633
+ def initialize(json)
634
+ @treatment_id = json['treatment_id']
635
+ @is_control = json['is_control']
636
+ @codename = json['codename']
637
+ @is_off_treatment = json['is_off_treatment']
638
+ @payload = json['payload']
639
+
640
+ validate
641
+ end
642
+ end
643
+ end
@@ -0,0 +1,56 @@
1
+ require 'json'
2
+
3
+ module FlaggerUtils
4
+ class Stat
5
+ attr_reader :name
6
+ attr_accessor :duration
7
+ attr_accessor :count
8
+
9
+ def self.compact(stats)
10
+ groups = stats.group_by {|stat| stat.name}
11
+ groups.values.map do |same_stats|
12
+ new_stat = Stat.new(same_stats[0].name)
13
+ new_stat.count = same_stats.reduce(0) {|count, stat| count + stat.count}
14
+ if same_stats[0].duration then
15
+ new_stat.duration = same_stats.reduce(0) {|duration, stat| duration + stat.duration * stat.count} / new_stat.count
16
+ end
17
+ new_stat
18
+ end
19
+ end
20
+
21
+ def initialize(name)
22
+ @name = name
23
+ @count = 0
24
+ end
25
+
26
+ def start()
27
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ end
29
+
30
+ def stop()
31
+ @end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ @duration = (@end_time - @start_time) * 1e9
33
+ @count = 1
34
+ end
35
+
36
+ def to_json(options={})
37
+ to_h.to_json(options)
38
+ end
39
+
40
+ def to_h()
41
+ if @duration then
42
+ {
43
+ name: name,
44
+ duration: @duration,
45
+ unit: 'ns',
46
+ count: @count
47
+ }
48
+ else
49
+ {
50
+ name: name,
51
+ count: @count
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/flagger.rb ADDED
@@ -0,0 +1,87 @@
1
+ require 'flagger/cloud'
2
+ require 'flagger/microservice'
3
+
4
+ class Flagger
5
+ class Flag
6
+ attr_accessor :flag_name
7
+ attr_accessor :delegate
8
+
9
+ def initialize(flag_name, delegate)
10
+ @flag_name = flag_name
11
+ @delegate = delegate
12
+ end
13
+
14
+ def treatment(entity)
15
+ @delegate&.treatment(self, entity) || 'off'
16
+ end
17
+
18
+ def payload(entity)
19
+ @delegate&.payload(self, entity) || nil
20
+ end
21
+
22
+ def eligible?(entity)
23
+ @delegate&.eligible?(self, entity) || false
24
+ end
25
+
26
+ def enabled?(entity)
27
+ @delegate&.enabled?(self, entity) || false
28
+ end
29
+ end
30
+
31
+ @@instance = Flagger.new
32
+
33
+ attr_accessor :env_key
34
+ attr_accessor :edge_url
35
+ attr_accessor :request_timeout
36
+ attr_accessor :delegate
37
+
38
+ def self.configure(options)
39
+ @@instance.configure(options)
40
+ @@instance
41
+ end
42
+
43
+ def configure(options)
44
+ @env_key = options[:env_key]
45
+ @edge_url = options[:edge_url]
46
+ @request_timeout = options[:request_timeout] || 10
47
+
48
+ @delegate&.cleanup
49
+ if @edge_url.nil? then
50
+ @delegate = FlaggerEnvironments::CloudDelegate.new(@env_key, @request_timeout)
51
+ else
52
+ @delegate = FlaggerEnvironments::MicroserviceDelegate.new(@env_key, @edge_url, @request_timeout)
53
+ end
54
+ end
55
+
56
+ def self.flag(flag_name)
57
+ Flag.new(flag_name, @@instance.delegate)
58
+ end
59
+
60
+ def self.publish(entities)
61
+ @@instance.delegate&.publish(entities)
62
+ end
63
+ end
64
+
65
+ class Airship
66
+ def initialize(options)
67
+ STDERR.puts 'The Airship class is deprecated.'
68
+
69
+ @env_key = options[:env_key]
70
+ end
71
+
72
+ def init()
73
+ Flagger.configure(env_key: @env_key)
74
+ end
75
+
76
+ def variation(flag, entity)
77
+ Flagger.flag(flag).treatment(entity)
78
+ end
79
+
80
+ def eligible?(flag, entity)
81
+ Flagger.flag(flag).eligible?(entity)
82
+ end
83
+
84
+ def enabled?(flag, entity)
85
+ Flagger.flag(flag).enabled?(entity)
86
+ end
87
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flagger
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Airship Dev Team
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-10-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: eventmachine
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faye-websocket
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.10.7
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.10.7
55
+ - !ruby/object:Gem::Dependency
56
+ name: lru_redux
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: dry-validation
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.12.2
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.12.2
83
+ description: Ruby SDK
84
+ email: support@airshiphq.com
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - lib/flagger.rb
90
+ - lib/flagger/cloud.rb
91
+ - lib/flagger/microservice.rb
92
+ - lib/flagger/models.rb
93
+ - lib/flagger/stat.rb
94
+ homepage: https://airshiphq.com
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ source_code_uri: https://github.com/airshiphq/flagger-ruby
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubyforge_project:
115
+ rubygems_version: 2.7.7
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Flagger Ruby SDK
119
+ test_files: []