updater 0.3.2 → 0.9.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.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.2
1
+ 0.9.0
@@ -35,7 +35,7 @@ module Updater
35
35
  logger.info "Max Workers set to #{@max_workers}"
36
36
  @timeout = options[:timeout] || 60
37
37
  logger.info "Timeout set to #{@timeout} sec."
38
- @current_workers = 1
38
+ @current_workers = 1 #we will actually add this worker the first time through the master loop
39
39
  @workers = {} #key is pid value is worker class
40
40
  @uptime = Time.now
41
41
  @downtime = Time.now
@@ -92,10 +92,9 @@ module Updater
92
92
  # * :timeout : how long can a worker be inactive before being killed
93
93
  # * :sockets: 0 or more IO objects that should wake up master to alert it that new data is availible
94
94
 
95
- def start(stream,options = {})
95
+ def start(options = {})
96
96
  initial_setup(options) #need this for logger
97
97
  logger.info "*** Starting Master Process***"
98
- @stream = stream
99
98
  logger.info "* Adding the first round of workers *"
100
99
  maintain_worker_count
101
100
  QUEUE_SIGS.each { |sig| trap_deferred(sig) }
@@ -121,13 +120,13 @@ module Updater
121
120
  logger.fatal "10 consecutive errors! Abandoning Master process"
122
121
  end
123
122
  stop # gracefully shutdown all workers on our way out
124
- logger.info "master process Exiting"
123
+ logger.warn "-=-=-=- master process Exiting -=-=-=-\n\n"
125
124
  end
126
125
 
127
126
  def stop(graceful = true)
128
127
  trap(:USR2,"IGNORE")
129
128
  [:INT,:TERM].each {|signal| trap(signal,"DEFAULT") }
130
- puts "Quitting. I need 30 seconds to stop my workers..."
129
+ puts "Quitting. I need 30 seconds to stop my workers..." unless @workers.empty?
131
130
  limit = Time.now + 30
132
131
  signal_each_worker(graceful ? :QUIT : :TERM)
133
132
  until @workers.empty? || Time.now > limit
@@ -140,15 +139,26 @@ module Updater
140
139
  def master_sleep
141
140
  begin
142
141
  timeout = calc_timeout
143
- logger.debug { "Sleeping for #{timeout}" } #TODO return to debug
142
+ logger.debug { "Sleeping for #{timeout}" }
144
143
  ready, _1, _2 = IO.select(@wakeup_set, nil, nil, timeout)
145
- return unless ready && ready.first #just wakeup and run maintance
146
- @signal_queue << :DATA unless ready.first == @self_pipe.first #somebody wants our attention
144
+ return unless ready && ready.first #timeout hit, just wakeup and run maintance
145
+ add_connection(ready.first) and return if ready.first.respond_to?(:accept) #open a new incomming connection
146
+ @signal_queue << :DATA unless ready.first == @self_pipe.first
147
147
  loop {ready.first.read_nonblock(16 * 1024)}
148
+ rescue EOFError #somebody closed thier connection
149
+ logger.info "closed socket connection"
150
+ @wakeup_set.delete ready.first
151
+ ready.first.close
148
152
  rescue Errno::EAGAIN, Errno::EINTR
149
153
  end
150
154
  end
151
155
 
156
+ def add_connection(server)
157
+ @wakeup_set << server.accept_nonblock
158
+ logger.info "opened socket connection: [#{@wakeup_set.last.addr.join(', ')}]"
159
+ rescue Errno::EAGAIN, Errno::EINTR
160
+ end
161
+
152
162
  def calc_timeout
153
163
  Time.now - [@uptime, @downtime].max < @timeout ? @timeout / 8 : 2*@timeout
154
164
  end
@@ -219,6 +229,7 @@ module Updater
219
229
 
220
230
  def add_worker(worker_number)
221
231
  worker = WorkerMonitor.new(worker_number,Updater::Util.tempio)
232
+ Update.orm.before_fork
222
233
  pid = Process.fork do
223
234
  fork_cleanup
224
235
  self.new(@pipe,worker).run
@@ -229,6 +240,7 @@ module Updater
229
240
 
230
241
  def fork_cleanup
231
242
  QUEUE_SIGS.each { |signal| trap(signal,"IGNORE") }
243
+ Update.orm.after_fork
232
244
  if @self_pipe !=nil
233
245
  @self_pipe.each {|io| io.close}
234
246
  end
@@ -327,8 +339,8 @@ module Updater
327
339
  wait_for(delay) if @continue
328
340
  rescue Exception=> e
329
341
  say "Caught exception in Job Loop"
330
- say e.message
331
- say "||=========\n|| Backtrace\n|| " + e.backtrace.join("\n|| ") + "\n||========="
342
+ say e.inspect
343
+ say "\n||=========\n|| Backtrace\n|| " + e.backtrace.join("\n|| ") + "\n||========="
332
344
  Update.clear_locks(self)
333
345
  exit; #die and be replaced by the master process
334
346
  end
@@ -392,8 +404,8 @@ module Updater
392
404
  #need to wait for another job
393
405
  t = Time.now + delay
394
406
  while Time.now < t && @continue
395
- delay = [@timeout,t-Time.now].min
396
- debug "No Jobs; #{name} sleeping for #{delay}: [#{@timeout},#{t - Time.now}].min"
407
+ delay = [@timeout/2,t-Time.now].min
408
+ debug "No Jobs; #{name} sleeping for #{delay}: [#{@timeout/2},#{t - Time.now}].min"
397
409
  wakeup,_1,_2 = select([@stream],nil,nil,delay)
398
410
  heartbeat
399
411
  if wakeup
@@ -19,13 +19,13 @@ module Updater
19
19
  storage_names[:default] = "updates"
20
20
 
21
21
  property :id, Serial
22
- property :time, Integer
23
- property :target, Class
24
- property :finder, String
25
- property :finder_args, Yaml
22
+ property :time, Integer, :index=>true
23
+ property :target, Class, :index=>:for_target
24
+ property :finder, String, :index=>:for_target
25
+ property :finder_args, Yaml, :index=>:for_target
26
26
  property :method, String
27
27
  property :method_args, Object, :lazy=>false
28
- property :name, String, :length=>255
28
+ property :name, String, :length=>255, :index=>true
29
29
  property :lock_name, String
30
30
  property :persistant, Boolean
31
31
 
@@ -112,6 +112,7 @@ module Updater
112
112
  return nxt.time - tnow
113
113
  end
114
114
 
115
+ #Returns the Locked Job or nil if no jobs were availible.
115
116
  def lock_next(worker)
116
117
  updates = worker_set
117
118
  unless updates.empty?
@@ -124,6 +125,7 @@ module Updater
124
125
  updates.each do |u|
125
126
  return u if u.lock(worker)
126
127
  end
128
+ return nil
127
129
  end
128
130
  rescue DataObjects::ConnectionError
129
131
  sleep 0.1
@@ -140,7 +142,32 @@ module Updater
140
142
  end
141
143
 
142
144
  def for(mytarget, myfinder, myfinder_args, myname=nil)
143
- #TODO
145
+ search = all(
146
+ :target=>mytarget,
147
+ :finder=>myfinder,
148
+ :finder_args=>myfinder_args,
149
+ :lock_name=>nil
150
+ )
151
+ myname ? search.all(:name=>myname ) : search
152
+ end
153
+
154
+ #For the server only, setup the connection to the database
155
+ def setup(options)
156
+ ::DataMapper.logger = options.delete(:logger)
157
+ ::DataMapper.setup(:default,options)
158
+ end
159
+
160
+ # For pooled connections it is necessary to empty the pool of the parents connections so that they
161
+ # do not comtiminate the child pool. Note that while Datamapper is thread safe, it is not safe accross a process fork.
162
+ def before_fork
163
+ return unless (defined? ::DataObjects::Pooling)
164
+ return if ::DataMapper.repository.adapter.kind_of?(::DataMapper::Adapters::Sqlite3Adapter)
165
+ ::DataMapper.logger.debug "+-+-+-+-+ Cleaning up connection pool (#{::DataObjects::Pooling.pools.length}) +-+-+-+-+"
166
+ ::DataObjects::Pooling.pools.each {|p| p.dispose}
167
+ end
168
+
169
+ def after_fork
170
+
144
171
  end
145
172
 
146
173
  private
@@ -152,7 +179,7 @@ module Updater
152
179
  options = {:lock_name=>nil,:limit=>limit, :order=>[:time.asc]}.merge(options)
153
180
  current.all(options)
154
181
  end
155
-
182
+
156
183
  def lock
157
184
 
158
185
  end
@@ -168,8 +195,8 @@ module Updater
168
195
  belongs_to :caller, :model=>Updater::ORM::DataMapper, :child_key=>[:caller_id]
169
196
  belongs_to :target, :model=>Updater::ORM::DataMapper, :child_key=>[:target_id]
170
197
 
171
- property :params, Object, :nullable=>true #:required=>false
172
- property :occasion, String, :nullable=>false #:required=>true
198
+ property :params, Object, :required=>false
199
+ property :occasion, String, :required=>true
173
200
  end
174
201
 
175
202
  end#ORM
@@ -217,6 +217,22 @@ module Updater
217
217
  NotImplementedError
218
218
  end
219
219
 
220
+ # This method is the generic way to setup the datastore. Options is a hash one of whose fields
221
+ # will be :logger, the logger instance to pass on to the ORM. The rest of the options are ORM
222
+ # spesific. The function should prepair a connection to the datastore using the given options.
223
+ # If the connection cannot be prepaired then an appropriate error should be raised.
224
+ def setup(options)
225
+ NotImplementedError
226
+ end
227
+
228
+ # This method is called by the child before a fork call. It allows the ORM to clean up any connections
229
+ # Made by the parent and establish new connections if necessary.
230
+ def before_fork
231
+
232
+ end
233
+
234
+ def after_fork
235
+
220
236
  # Optional, but strongly recomended.
221
237
  #
222
238
  # For any datastore that permits, return and Array of all delayed, chained, and current but not locked jobs that reference
data/lib/updater/setup.rb CHANGED
@@ -14,6 +14,10 @@ module Updater
14
14
  new(config_file).stop
15
15
  end
16
16
 
17
+ def client_setup(options = {})
18
+ new(config_file, options).client_setup
19
+ end
20
+
17
21
  def monitor
18
22
 
19
23
  end
@@ -27,44 +31,84 @@ module Updater
27
31
  end
28
32
  end
29
33
 
30
- ROOT = File.dirname(self.config_file)
34
+ ROOT = File.dirname(self.config_file || Dir.pwd)
31
35
 
32
- def initialize(file_or_hash)
36
+ #extended used for clients who wnat to override parameters
37
+ def initialize(file_or_hash, extended = {})
33
38
  @options = file_or_hash.kind_of?(Hash) ? file_or_hash : load_file(file_or_hash)
39
+ @options.merge(extended)
34
40
  @options[:pid_file] ||= File.join(ROOT,'updater.pid')
35
41
  @options[:host] ||= "localhost"
36
- @logger = Logger.new(@options[:log_file] || STDOUT)
42
+ @logger = @options[:logger] || Logger.new(@options[:log_file] || STDOUT)
37
43
  level = Logger::SEV_LABEL.index(@options[:log_level].upcase) if @options[:log_level]
38
44
  @logger.level = level || Logger::WARN
39
45
  end
40
46
 
41
47
  def start
42
- @logger.warn "Starting Loop"
43
48
  pid = Process.fork do
44
49
  _start
45
50
  end
46
- @logger.warn "Rake Successfully started Master Loop at pid #{pid}"
51
+ @logger.warn "Successfully started Master Loop at pid #{pid}"
52
+ puts "Job Queue Processor Started at PID: #{pid}"
47
53
  end
48
54
 
49
55
  def stop
50
56
  Process.kill("TERM",File.read(@options[:pid_file]).to_i)
51
57
  end
52
58
 
53
- def client
59
+ # The client is responcible for loading classes and making connections. We will simply setup the Updater spesifics
60
+ def client_setup
61
+ set_orm
62
+
63
+ if @options[:socket] && File.exists?(@options[:socket])
64
+ Updater::Update.socket = UNIXSocket.new(@options[:socket])
65
+ elsif @options[:udp]
66
+ socket = UDPSocket.new()
67
+ socket.connect(@options[:host],@options[:udp])
68
+ Updater::Update.socket = socket
69
+ elsif @options[:tcp]
70
+ Updater::Update.socket = TCPSocket.new(@options[:host],@options[:tcp])
71
+ elsif @options[:remote]
72
+ raise NotImplimentedError #For future Authenticated Http Rest Server
73
+ end
74
+
75
+ #set PID
76
+ if File.exists? @options[:pid_file]
77
+ Updater::Update.pid = File.read(@options[:pid_file]).strip
78
+ end
79
+
54
80
 
55
81
  end
56
82
 
57
83
  private
58
84
 
85
+ def set_orm
86
+ #don't setup twice. Client setup might call this as part of server setup in which case it is already done
87
+ return false if Updater::Update.orm
88
+ orm = @options[:orm] || "datamapper"
89
+ case orm.downcase
90
+ when "datamapper"
91
+ require 'updater/orm/datamapper'
92
+ Updater::Update.orm = ORM::DataMapper
93
+ when "mongodb"
94
+ require 'updater/orm/mongodb'
95
+ Updater::Update.orm = ORM::MongoDB
96
+ when "activerecord"
97
+ require 'updater/orm/activerecord'
98
+ Updater::Update.orm = ORM::ActiveRecord
99
+ else
100
+ require "update/orm/#{orm}"
101
+ Updater::Update.orm = Object.const_get("ORM").const_get(orm.capitalize)
102
+ end
103
+ @logger.info "Data store '#{orm}' selected"
104
+ end
105
+
59
106
  def _start
60
107
  #set ORM
61
- require 'updater/orm/datamapper'
62
- Updater::Update.orm = ORM::DataMapper
63
-
108
+ set_orm
64
109
  #init DataStore
65
- DataMapper.logger = @logger
66
- DataMapper.setup(:default, :adapter=>'sqlite3', :database=>'./simulated.db')
67
-
110
+ default_options = {:adapter=>'sqlite3', :database=>'./default.db'}
111
+ Updater::Update.orm.setup((@options[:database] || @options[:orm_setup] || default_options).merge(:logger=>@logger))
68
112
  #load Models
69
113
 
70
114
  models = @options[:models] || Dir.glob('./app/models/**/*.rb')
@@ -73,11 +117,13 @@ module Updater
73
117
  end
74
118
 
75
119
  #establish Connections
120
+ @options[:host] ||= 'localhost'
76
121
  #Unix Socket -- name at @options[:socket]
77
122
  if @options[:socket]
78
123
  File.unlink @options[:socket] if File.exists? @options[:socket]
79
124
  @options[:sockets] ||= []
80
125
  @options[:sockets] << UNIXServer.new(@options[:socket])
126
+ @logger.info "Now listening on UNIX Socket: #{@options[:socket]}"
81
127
  end
82
128
 
83
129
  #UDP potentially unsafe user monitor server for Authenticated Connections (TODO)
@@ -86,28 +132,39 @@ module Updater
86
132
  udp = UDPSocket.new
87
133
  udp.bind(@options[:host],@options[:udp])
88
134
  @options[:sockets] << udp
135
+ @logger.info "Now listening for UDP: #{@options[:host]}:#{@options[:udp]}"
89
136
  end
90
137
 
91
138
  #TCP Unsafe user monitor server for Authenticated Connections (TODO)
92
139
  if @options[:tcp]
93
140
  @options[:sockets] ||= []
94
141
  @options[:sockets] << TCPServer.new(@options[:host],@options[:tcp])
142
+ @logger.info "Now listening for TCP: #{@options[:host]}:#{@options[:tcp]}"
95
143
  end
96
144
 
97
145
  #Log PID
98
146
  File.open(@options[:pid_file],'w') { |f| f.write(Process.pid.to_s)}
147
+
148
+ client_setup
149
+
99
150
  #start Worker
100
- require 'updater/fork_worker'
101
- worker_class = ForkWorker
151
+ worker = @options[:worker] || 'fork' #todo make this line windows safe
152
+ require "updater/#{worker}_worker"
153
+ worker_class = Updater.const_get("#{worker.capitalize}Worker")
102
154
  worker_class.logger = @logger
155
+ @logger.info "Using #{worker_class.to_s} to run jobs:"
103
156
  worker_class.start(@options)
157
+ File.unlink(@options[:pid_file])
158
+ File.unlink @options[:socket] if @options[:socket] && File.exists?(@options[:socket])
104
159
  end
105
160
 
106
161
  def load_file(file)
107
162
  return {} if file.nil?
108
163
  file = File.open(file) if file.kind_of?(String)
109
164
  @config_file = File.expand_path(file.path)
110
- YAML.load(ERB.new(File.read(file)).result(binding)) || {}
165
+ YAML.load(ERB.new(file.read).result(binding)) || {}
166
+ ensure
167
+ file.close
111
168
  end
112
169
  end
113
170
  end
@@ -22,7 +22,12 @@ module Updater
22
22
  ensure
23
23
  run_chain :success if ret
24
24
  run_chain :ensure
25
- @orm.destroy unless @orm.persistant
25
+ begin
26
+ @orm.destroy unless @orm.persistant
27
+ rescue DataObjects::ConnectionError
28
+ sleep 0.1
29
+ retry
30
+ end
26
31
  end
27
32
  ret
28
33
  end
@@ -33,7 +38,7 @@ module Updater
33
38
 
34
39
  def target
35
40
  target = @orm.finder.nil? ? @orm.target : @orm.target.send(@orm.finder,@orm.finder_args)
36
- raise TargetMissingError, "Class:'#{@orm.target}' Finder:'#{@orm.finder}', Args:'#{@orm.finder_args.inspect}'" unless target
41
+ raise TargetMissingError, "Target missing --Class:'#{@orm.target}' Finder:'#{@orm.finder}', Args:'#{@orm.finder_args.inspect}'" unless target
37
42
  target
38
43
  end
39
44
 
@@ -49,17 +54,23 @@ module Updater
49
54
  @orm.name
50
55
  end
51
56
 
52
- #This is the appropriate valut ot use for a chanable field value
57
+ #This is the appropriate value to use for a chanable field value
53
58
  def id
54
59
  @orm.id
55
60
  end
56
61
 
62
+ def ==(other)
63
+ id = other.id
64
+ end
65
+
57
66
  def persistant?
58
67
  @orm.persistant
59
68
  end
60
69
 
61
70
  def inspect
62
71
  "#<Updater::Update target=#{target.inspect} time=#{orm.time}>"
72
+ rescue TargetMissingError
73
+ "#<Updater::Update target=<missing> time=#{orm.time}>"
63
74
  end
64
75
 
65
76
  private
@@ -93,6 +104,9 @@ module Updater
93
104
  chains.each do |job|
94
105
  Update.new(job.target).run(self,job.params)
95
106
  end
107
+ rescue NameError
108
+ puts @orm.inspect
109
+ raise
96
110
  end
97
111
 
98
112
  class << self
@@ -100,6 +114,16 @@ module Updater
100
114
  #This attribute must be set to some ORM that will persist the data
101
115
  attr_accessor :orm
102
116
 
117
+ #remove once Bug is discovered
118
+ def orm=(input)
119
+ raise ArgumentError, "Must set ORM to and appropriate class" unless input.kind_of? Class
120
+ @orm = input
121
+ end
122
+
123
+ # This is an open IO socket that will be writen to when a job is scheduled. If it is unset
124
+ # then @pid is signaled instead.
125
+ attr_accessor :socket
126
+
103
127
  #Gets a single job form the queue, locks and runs it. it returns the number of second
104
128
  #Until the next job is scheduled, or 0 is there are more current jobs, or nil if there
105
129
  #are no jobs scheduled.
@@ -198,8 +222,12 @@ module Updater
198
222
  # Advanced: This method allows values to be passed directly to the ORM layer's create method.
199
223
  # use +at+ and friends for everyday use cases.
200
224
  def schedule(hash)
201
- new(@orm.create(hash))
225
+ r = new(@orm.create(hash))
202
226
  signal_worker
227
+ r
228
+ rescue NoMethodError
229
+ raise ArgumentError, "ORM not initialized!" if @orm.nil?
230
+ raise
203
231
  end
204
232
 
205
233
  # Create a new job having the same charistics as the old, except that 'hash' will override the original.
@@ -237,7 +265,9 @@ module Updater
237
265
  #
238
266
  # <Array[Updater]> unless name is given then only a single [Updater] instance.
239
267
  def for(target,name=nil)
240
- #TODO
268
+ target,finder,args = target_for(target)
269
+ ret = @orm.for(target,finder,args,name).map {|i| new(i)}
270
+ name ? ret.first : ret
241
271
  end
242
272
 
243
273
  #The time class used by Updater. See time=
@@ -286,6 +316,8 @@ module Updater
286
316
  #is set then an attempt will be made to signal the worker any
287
317
  #time a new update is made.
288
318
  #
319
+ #The PID will not be signaled if @socket is availible, but should be set as a back-up
320
+ #
289
321
  #If pid is not set, or is set to nil then the scheduleing program
290
322
  #is responcible for waking-up a potentially sleeping worker process
291
323
  #in another way.
@@ -295,6 +327,7 @@ module Updater
295
327
  Process::kill 0, @pid
296
328
  @pid
297
329
  rescue Errno::ESRCH, ArgumentError
330
+ @pid = nil
298
331
  raise ArgumentError, "PID was invalid"
299
332
  end
300
333
 
@@ -304,7 +337,9 @@ module Updater
304
337
 
305
338
  private
306
339
  def signal_worker
307
- if @pid
340
+ if @socket
341
+ @socket.write '.'
342
+ elsif @pid
308
343
  Process::kill "USR2", @pid
309
344
  end
310
345
  end
@@ -8,29 +8,49 @@ describe "named request" do
8
8
 
9
9
  before(:each) do
10
10
  Foo.all.destroy!
11
+ Update.clear_all
11
12
  end
12
13
 
13
14
  it "should be found by name when target is an instance" do
14
15
  f = Foo.create(:name=>'Honey')
15
16
  u = Update.immidiate(f,:bar,[:named],:name=>'Now')
16
17
  u.name.should ==("Now")
17
- pending "'for' not implemented"
18
18
  Update.for(f, "Now").should ==(u)
19
19
  end
20
20
 
21
21
  it "should be found by name when target is a class" do
22
22
  u = Update.immidiate(Foo,:bar,[:named],:name=>'Now')
23
23
  u.name.should ==("Now")
24
- pending "'for' not implemented"
25
24
  Update.for(Foo, "Now").should ==(u)
26
25
  end
27
26
 
28
27
  it "should return all updates for a given target" do
29
- u1 = Update.immidiate(Foo,:bar,[:arg1,:arg2])
28
+ u1 = Update.immidiate(Foo,:bar,[:arg1,:arg2], :name=>'First')
30
29
  u2 = Update.immidiate(Foo,:bar,[:arg3,:arg4])
31
- pending "'for' not implemented"
32
30
  Update.for(Foo).should include(u1,u2)
33
31
  end
34
-
32
+
33
+ #locked updates are already running and can therefore not be modified
34
+ it "should not include locked updates" do
35
+ u = Update.immidiate(Foo,:bar,[:named],:name=>'Now')
36
+ u.orm.lock(Struct.new(:name).new('test_worker'))
37
+ u.orm.should be_locked
38
+ Update.for(Foo).should_not include(u)
39
+ Update.for(Foo).should be_empty
40
+ end
41
+
42
+ it "should not return rusults with the wrong name" do
43
+ u = Update.immidiate(Foo,:bar,[:named],:name=>'Now')
44
+ u.name.should ==("Now")
45
+ Update.for(Foo, "Then").should be_nil
46
+ end
47
+
48
+ it "should not return results for the wring target" do
49
+ f = Foo.create(:name=>'Honey')
50
+ g = Foo.create(:name=>'Sweetie Pie')
51
+ u = Update.immidiate(f,:bar,[:named],:name=>'Now')
52
+ Update.for(f).should include(u)
53
+ Update.for(g).should be_empty
54
+ end
35
55
 
36
56
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: updater
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John F. Miller
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2010-02-18 00:00:00 -08:00
12
+ date: 2010-03-26 00:00:00 -07:00
13
13
  default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency