flagger 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/flagger/cloud.rb +287 -0
- data/lib/flagger/microservice.rb +63 -0
- data/lib/flagger/models.rb +643 -0
- data/lib/flagger/stat.rb +56 -0
- data/lib/flagger.rb +87 -0
- metadata +119 -0
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
|
data/lib/flagger/stat.rb
ADDED
@@ -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: []
|