nomade 0.1.3 → 0.1.4
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 +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
|