nomade 0.1.3 → 0.1.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/nomade/deployer.rb +209 -24
- data/lib/nomade/exceptions.rb +15 -0
- data/lib/nomade/hooks.rb +4 -0
- data/lib/nomade/http.rb +29 -4
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 123b90eb6930f46b12b75aee376034b51c623ae5e788149bcff15ca2dccaf8b9
|
4
|
+
data.tar.gz: a0aef251645a73ee2fb61f316298c354316d27e02a2cba59bb3c22e113e06acb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7a0dd940d4b32facd28d3e81a2fdb49d97cc0a43205e93997d913d316101902d9922b8e87bca59bd25b84752d1535a19a60b622a1cf85ecd2baf8ae47f965ea6
|
7
|
+
data.tar.gz: 4598198f4be75e00c44f6bf9a0185b013e135ceead95dc25d6ccef0de6bf13f5913cf699fb45f9b3f8a6aaaa7b7cad008366cdc3f131c2c06ec982ec2b6875eb
|
data/lib/nomade/deployer.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require "base64"
|
2
|
+
|
1
3
|
module Nomade
|
2
4
|
class Deployer
|
3
5
|
attr_reader :nomad_job
|
@@ -14,6 +16,10 @@ module Nomade
|
|
14
16
|
Nomade::Hooks::DEPLOY_RUNNING => [],
|
15
17
|
Nomade::Hooks::DEPLOY_FINISHED => [],
|
16
18
|
Nomade::Hooks::DEPLOY_FAILED => [],
|
19
|
+
|
20
|
+
Nomade::Hooks::DISPATCH_RUNNING => [],
|
21
|
+
Nomade::Hooks::DISPATCH_FINISHED => [],
|
22
|
+
Nomade::Hooks::DISPATCH_FAILED => [],
|
17
23
|
}
|
18
24
|
add_hook(Nomade::Hooks::DEPLOY_FAILED, lambda {|hook_type, nomad_job, messages|
|
19
25
|
@logger.error "Failing deploy:"
|
@@ -21,6 +27,12 @@ module Nomade
|
|
21
27
|
@logger.error "- #{message}"
|
22
28
|
end
|
23
29
|
})
|
30
|
+
add_hook(Nomade::Hooks::DISPATCH_FAILED, lambda {|hook_type, nomad_job, messages|
|
31
|
+
@logger.error "Failing dispatch:"
|
32
|
+
messages.each do |message|
|
33
|
+
@logger.error "- #{message}"
|
34
|
+
end
|
35
|
+
})
|
24
36
|
|
25
37
|
self
|
26
38
|
end
|
@@ -31,6 +43,15 @@ module Nomade
|
|
31
43
|
@deployment_id = nil
|
32
44
|
|
33
45
|
self
|
46
|
+
rescue Nomade::HttpConnectionError => e
|
47
|
+
[e.class.to_s, e.message, "Http connection error, exiting!"].compact.uniq.map{|l| @logger.error(l)}
|
48
|
+
exit(7)
|
49
|
+
rescue Nomade::HttpBadResponse => e
|
50
|
+
[e.class.to_s, e.message, "Http bad response, exiting!"].compact.uniq.map{|l| @logger.error(l)}
|
51
|
+
exit(8)
|
52
|
+
rescue Nomade::HttpBadContentType => e
|
53
|
+
[e.class.to_s, e.message, "Http unexpected content type!"].compact.uniq.map{|l| @logger.error(l)}
|
54
|
+
exit(9)
|
34
55
|
end
|
35
56
|
|
36
57
|
def add_hook(hook, hook_method)
|
@@ -40,12 +61,20 @@ module Nomade
|
|
40
61
|
@hooks[Nomade::Hooks::DEPLOY_FINISHED] << hook_method
|
41
62
|
elsif Nomade::Hooks::DEPLOY_FAILED == hook
|
42
63
|
@hooks[Nomade::Hooks::DEPLOY_FAILED] << hook_method
|
64
|
+
elsif Nomade::Hooks::DISPATCH_RUNNING == hook
|
65
|
+
@hooks[Nomade::Hooks::DISPATCH_RUNNING] << hook_method
|
66
|
+
elsif Nomade::Hooks::DISPATCH_FINISHED == hook
|
67
|
+
@hooks[Nomade::Hooks::DISPATCH_FINISHED] << hook_method
|
68
|
+
elsif Nomade::Hooks::DISPATCH_FAILED == hook
|
69
|
+
@hooks[Nomade::Hooks::DISPATCH_FAILED] << hook_method
|
43
70
|
else
|
44
71
|
raise "#{hook} not supported!"
|
45
72
|
end
|
46
73
|
end
|
47
74
|
|
48
75
|
def deploy!
|
76
|
+
check_for_job_init
|
77
|
+
|
49
78
|
run_hooks(Nomade::Hooks::DEPLOY_RUNNING, @nomad_job, nil)
|
50
79
|
_plan
|
51
80
|
_deploy
|
@@ -67,6 +96,62 @@ module Nomade
|
|
67
96
|
rescue Nomade::DeploymentFailedError => e
|
68
97
|
run_hooks(Nomade::Hooks::DEPLOY_FAILED, @nomad_job, [e.class.to_s, e.message, "Couldn't deploy succesfully, exiting!"].compact.uniq)
|
69
98
|
exit(6)
|
99
|
+
rescue Nomade::HttpConnectionError => e
|
100
|
+
run_hooks(Nomade::Hooks::DEPLOY_FAILED, @nomad_job, [e.class.to_s, e.message, "Http connection error, exiting!"].compact.uniq)
|
101
|
+
exit(7)
|
102
|
+
rescue Nomade::HttpBadResponse => e
|
103
|
+
run_hooks(Nomade::Hooks::DEPLOY_FAILED, @nomad_job, [e.class.to_s, e.message, "Http bad response, exiting!"].compact.uniq)
|
104
|
+
exit(8)
|
105
|
+
rescue Nomade::HttpBadContentType => e
|
106
|
+
run_hooks(Nomade::Hooks::DEPLOY_FAILED, @nomad_job, [e.class.to_s, e.message, "Http unexpected content type!"].compact.uniq)
|
107
|
+
exit(9)
|
108
|
+
end
|
109
|
+
|
110
|
+
def dispatch!(payload_data: nil, payload_metadata: {})
|
111
|
+
check_for_job_init
|
112
|
+
|
113
|
+
run_hooks(Nomade::Hooks::DISPATCH_RUNNING, @nomad_job, nil)
|
114
|
+
_dispatch(payload_data, payload_metadata)
|
115
|
+
run_hooks(Nomade::Hooks::DISPATCH_FINISHED, @nomad_job, nil)
|
116
|
+
rescue Nomade::DispatchMetaDataFormattingError => e
|
117
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Metadata wrongly formatted, exiting!"].compact.uniq)
|
118
|
+
exit(10)
|
119
|
+
rescue Nomade::DispatchMissingMetaData => e
|
120
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Required metadata missing, exiting!"].compact.uniq)
|
121
|
+
exit(11)
|
122
|
+
rescue Nomade::DispatchUnknownMetaData => e
|
123
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Unknown metadata sent to server, exiting!"].compact.uniq)
|
124
|
+
exit(12)
|
125
|
+
rescue Nomade::DispatchMissingPayload => e
|
126
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Job requires payload, but payload isn't set!"].compact.uniq)
|
127
|
+
exit(20)
|
128
|
+
rescue Nomade::DispatchPayloadNotAllowed => e
|
129
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Job does not allow payload!"].compact.uniq)
|
130
|
+
exit(21)
|
131
|
+
rescue Nomade::DispatchPayloadUnknown => e
|
132
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "API error!"].compact.uniq)
|
133
|
+
exit(22)
|
134
|
+
rescue Nomade::DispatchWrongJobType => e
|
135
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Job has wrong job-type"].compact.uniq)
|
136
|
+
exit(30)
|
137
|
+
rescue Nomade::DispatchNotParamaterized => e
|
138
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Job is not paramaterized"].compact.uniq)
|
139
|
+
exit(31)
|
140
|
+
rescue Nomade::AllocationFailedError => e
|
141
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Allocation failed with errors, exiting!"].compact.uniq)
|
142
|
+
exit(40)
|
143
|
+
rescue Nomade::GeneralError => e
|
144
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "GeneralError hit, exiting!"].compact.uniq)
|
145
|
+
exit(1)
|
146
|
+
rescue Nomade::HttpConnectionError => e
|
147
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Http connection error, exiting!"].compact.uniq)
|
148
|
+
exit(7)
|
149
|
+
rescue Nomade::HttpBadResponse => e
|
150
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Http bad response, exiting!"].compact.uniq)
|
151
|
+
exit(8)
|
152
|
+
rescue Nomade::HttpBadContentType => e
|
153
|
+
run_hooks(Nomade::Hooks::DISPATCH_FAILED, @nomad_job, [e.class.to_s, e.message, "Http unexpected content type!"].compact.uniq)
|
154
|
+
exit(9)
|
70
155
|
end
|
71
156
|
|
72
157
|
def stop!(purge = false)
|
@@ -75,6 +160,12 @@ module Nomade
|
|
75
160
|
|
76
161
|
private
|
77
162
|
|
163
|
+
def check_for_job_init
|
164
|
+
unless @nomad_job
|
165
|
+
raise Nomade::GeneralError.new("Did you forget to run init_job?")
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
78
169
|
def run_hooks(hook, job, messages)
|
79
170
|
@hooks[hook].each do |hook_method|
|
80
171
|
hook_method.call(hook, job, messages)
|
@@ -112,32 +203,36 @@ module Nomade
|
|
112
203
|
@http.create_job(@nomad_job)
|
113
204
|
end
|
114
205
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
eval_status
|
122
|
-
|
123
|
-
|
124
|
-
|
206
|
+
if @evaluation_id.empty?
|
207
|
+
@logger.info "Parameterized job without evaluation, no more work needed"
|
208
|
+
else
|
209
|
+
@logger.info "EvaluationID: #{@evaluation_id}"
|
210
|
+
@logger.info "#{@evaluation_id} Waiting until evaluation is complete"
|
211
|
+
eval_status = nil
|
212
|
+
while(eval_status != "complete") do
|
213
|
+
evaluation = @http.evaluation_request(@evaluation_id)
|
214
|
+
@deployment_id ||= evaluation["DeploymentID"]
|
215
|
+
eval_status = evaluation["Status"]
|
216
|
+
@logger.info "."
|
217
|
+
sleep(1)
|
218
|
+
end
|
125
219
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
220
|
+
@logger.info "Waiting until allocations are no longer pending"
|
221
|
+
allocations = []
|
222
|
+
until !allocations.empty? && allocations.all?{|a| a["ClientStatus"] != "pending"}
|
223
|
+
@logger.info "."
|
224
|
+
sleep(2)
|
225
|
+
allocations = @http.allocations_from_evaluation_request(@evaluation_id)
|
226
|
+
end
|
133
227
|
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
228
|
+
case @nomad_job.job_type
|
229
|
+
when "service"
|
230
|
+
service_deploy
|
231
|
+
when "batch"
|
232
|
+
batch_deploy
|
233
|
+
else
|
234
|
+
raise Nomade::GeneralError.new("Job-type '#{@nomad_job.job_type}' not implemented")
|
235
|
+
end
|
141
236
|
end
|
142
237
|
rescue Nomade::AllocationFailedError => e
|
143
238
|
e.allocations.each do |allocation|
|
@@ -187,6 +282,96 @@ module Nomade
|
|
187
282
|
raise
|
188
283
|
end
|
189
284
|
|
285
|
+
def _dispatch(payload_data, payload_metadata)
|
286
|
+
@logger.info "Dispatching #{@nomad_job.job_name} (#{@nomad_job.job_type}) with #{@nomad_job.image_name_and_version}"
|
287
|
+
@logger.info "URL: #{@nomad_endpoint}/ui/jobs/#{@nomad_job.job_name}"
|
288
|
+
|
289
|
+
@logger.info "Running sanity checks.."
|
290
|
+
|
291
|
+
if @nomad_job.job_type != "batch"
|
292
|
+
raise DispatchWrongJobType.new("Job-type for #{@nomad_job.job_name} is \"#{@nomad_job.job_type}\" but should be \"batch\"")
|
293
|
+
end
|
294
|
+
|
295
|
+
if @nomad_job.configuration(:hash)["ParameterizedJob"] == nil
|
296
|
+
raise DispatchNotParamaterized.new("Job doesn't seem to be a paramaterized job, returned JobHash doesn't contain ParameterizedJob-key")
|
297
|
+
end
|
298
|
+
|
299
|
+
payload_data = if payload_data
|
300
|
+
Base64.encode64(payload_data)
|
301
|
+
else
|
302
|
+
nil
|
303
|
+
end
|
304
|
+
|
305
|
+
payload_metadata = if payload_metadata == nil
|
306
|
+
{}
|
307
|
+
else
|
308
|
+
Hash[payload_metadata.collect{|k,v| [k.to_s, v]}]
|
309
|
+
payload_metadata.each do |key, value|
|
310
|
+
unless [key, value].map(&:class) == [String, String]
|
311
|
+
raise Nomade::DispatchMetaDataFormattingError.new("Dispatch metadata must only be strings: #{key}(#{key.class}) = #{value}(#{value.class})")
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
meta_required = @nomad_job.configuration(:hash)["ParameterizedJob"]["MetaRequired"]
|
317
|
+
meta_optional = @nomad_job.configuration(:hash)["ParameterizedJob"]["MetaOptional"]
|
318
|
+
payload = @nomad_job.configuration(:hash)["ParameterizedJob"]["Payload"]
|
319
|
+
|
320
|
+
if meta_required
|
321
|
+
@logger.info "Dispatch job expects the following metakeys: #{meta_required.join(", ")}"
|
322
|
+
meta_required.each do |required_key|
|
323
|
+
unless payload_metadata.keys.include?(required_key)
|
324
|
+
raise Nomade::DispatchMissingMetaData.new("Dispatch job expects metakey #{required_key} but it was not set")
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
allowed_meta_tags = [meta_required, meta_optional].flatten.uniq
|
330
|
+
@logger.info "Dispatch job allows the following metakeys: #{allowed_meta_tags.join(", ")}"
|
331
|
+
if payload_metadata
|
332
|
+
payload_metadata.keys.each do |metadata_key|
|
333
|
+
unless allowed_meta_tags.include?(metadata_key)
|
334
|
+
raise Nomade::DispatchUnknownMetaData.new("Dispatch job does not allow #{metadata_key} to be set!")
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
case payload
|
340
|
+
when "optional", ""
|
341
|
+
@logger.info "Expectation for Payload is: optional"
|
342
|
+
when "required"
|
343
|
+
@logger.info "Expectation for Payload is: required"
|
344
|
+
|
345
|
+
unless payload_data
|
346
|
+
raise Nomade::DispatchMissingPayload.new("Dispatch job expects payload_data, but we don't supply any!")
|
347
|
+
end
|
348
|
+
when "forbidden"
|
349
|
+
@logger.info "Expectation for Payload is: forbidden"
|
350
|
+
|
351
|
+
if payload_data
|
352
|
+
raise Nomade::DispatchPayloadNotAllowed.new("Dispatch job do not allow payload_data!")
|
353
|
+
end
|
354
|
+
else
|
355
|
+
raise Nomade::DispatchPayloadUnknown.new("Invalid value for [\"ParameterizedJob\"][\"Payload\"] = #{payload}")
|
356
|
+
end
|
357
|
+
|
358
|
+
@logger.info "Checking cluster for connectivity and capacity.."
|
359
|
+
plan_data = @http.plan_job(@nomad_job)
|
360
|
+
|
361
|
+
dispatch_job = @http.dispatch_job(@nomad_job, payload_data: payload_data, payload_metadata: payload_metadata)
|
362
|
+
@evaluation_id = dispatch_job["EvalID"]
|
363
|
+
|
364
|
+
@logger.info "Waiting until allocations are no longer pending"
|
365
|
+
allocations = []
|
366
|
+
until !allocations.empty? && allocations.all?{|a| a["ClientStatus"] != "pending"}
|
367
|
+
@logger.info "."
|
368
|
+
sleep(2)
|
369
|
+
allocations = @http.allocations_from_evaluation_request(@evaluation_id)
|
370
|
+
end
|
371
|
+
|
372
|
+
batch_deploy
|
373
|
+
end
|
374
|
+
|
190
375
|
def service_deploy
|
191
376
|
@logger.info "Waiting until tasks are placed"
|
192
377
|
deploy_timeout = Time.now.utc + @timeout
|
data/lib/nomade/exceptions.rb
CHANGED
@@ -16,4 +16,19 @@ module Nomade
|
|
16
16
|
|
17
17
|
class UnsupportedDeploymentMode < StandardError; end
|
18
18
|
class FailedTaskGroupPlan < StandardError; end
|
19
|
+
|
20
|
+
class DispatchWrongJobType < StandardError; end
|
21
|
+
class DispatchNotParamaterized < StandardError; end
|
22
|
+
|
23
|
+
class DispatchMetaDataFormattingError < StandardError; end
|
24
|
+
class DispatchMissingMetaData < StandardError; end
|
25
|
+
class DispatchUnknownMetaData < StandardError; end
|
26
|
+
|
27
|
+
class DispatchMissingPayload < StandardError; end
|
28
|
+
class DispatchPayloadNotAllowed < StandardError; end
|
29
|
+
class DispatchPayloadUnknown < StandardError; end
|
30
|
+
|
31
|
+
class HttpConnectionError < StandardError; end
|
32
|
+
class HttpBadResponse < StandardError; end
|
33
|
+
class HttpBadContentType < StandardError; end
|
19
34
|
end
|
data/lib/nomade/hooks.rb
CHANGED
data/lib/nomade/http.rb
CHANGED
@@ -154,12 +154,34 @@ module Nomade
|
|
154
154
|
raise
|
155
155
|
end
|
156
156
|
|
157
|
+
def dispatch_job(nomad_job, payload_data: nil, payload_metadata: nil)
|
158
|
+
if payload_metadata.class != Hash
|
159
|
+
raise DispatchMetaDataFormattingError.new("Expected #{payload_metadata} to be a Hash, but received #{payload_metadata.class}")
|
160
|
+
end
|
161
|
+
|
162
|
+
req_body = JSON.generate({
|
163
|
+
"Payload": payload_data,
|
164
|
+
"Meta": payload_metadata,
|
165
|
+
}.delete_if { |k, v| v.nil? })
|
166
|
+
|
167
|
+
res_body = _request(:post, "/v1/job/#{nomad_job.job_name}/dispatch", body: req_body)
|
168
|
+
JSON.parse(res_body)
|
169
|
+
rescue StandardError => e
|
170
|
+
Nomade.logger.fatal "HTTP Request failed (#{e.message})"
|
171
|
+
raise
|
172
|
+
end
|
173
|
+
|
157
174
|
private
|
158
175
|
|
159
176
|
def _request(request_type, path, body: nil, total_retries: 0, expected_content_type: "application/json")
|
160
177
|
uri = URI("#{@nomad_endpoint}#{path}")
|
161
178
|
|
162
179
|
http = Net::HTTP.new(uri.host, uri.port)
|
180
|
+
http.open_timeout = 10
|
181
|
+
http.read_timeout = 10
|
182
|
+
http.write_timeout = 10
|
183
|
+
http.ssl_timeout = 10
|
184
|
+
|
163
185
|
if @nomad_endpoint.include?("https://")
|
164
186
|
http.use_ssl = true
|
165
187
|
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
@@ -181,24 +203,27 @@ module Nomade
|
|
181
203
|
res = begin
|
182
204
|
retries ||= 0
|
183
205
|
http.request(req)
|
184
|
-
rescue Timeout::Error, Errno::ETIMEDOUT, Errno::EINVAL, Errno::ECONNRESET, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError
|
206
|
+
rescue Timeout::Error, Errno::ETIMEDOUT, Errno::EINVAL, Errno::ECONNRESET, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError => e
|
185
207
|
if retries < total_retries
|
186
208
|
retries += 1
|
187
209
|
sleep 1
|
188
210
|
retry
|
189
211
|
else
|
190
|
-
raise
|
212
|
+
raise HttpConnectionError.new("#{e.class.to_s} - #{e.message}")
|
191
213
|
end
|
192
214
|
end
|
193
215
|
|
194
|
-
|
216
|
+
if res.code != "200"
|
217
|
+
raise HttpBadResponse.new("Bad response (not 200) but #{res.code}: #{res.body}")
|
218
|
+
end
|
219
|
+
|
195
220
|
if res.content_type != expected_content_type
|
196
221
|
# Sometimes the log endpoint doesn't set content_type on no content
|
197
222
|
# https://github.com/hashicorp/nomad/issues/7264
|
198
223
|
if res.content_type == nil && expected_content_type == "text/plain"
|
199
224
|
# don't raise
|
200
225
|
else
|
201
|
-
raise
|
226
|
+
raise HttpBadContentType.new("Expected #{expected_content_type} but got #{res.content_type}")
|
202
227
|
end
|
203
228
|
end
|
204
229
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: nomade
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kasper Grubbe
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-04-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: yell
|