flagger 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|