furnish 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,410 @@
1
+ require 'timeout'
2
+ require 'furnish/vm'
3
+ require 'furnish/logger'
4
+ require 'furnish/provisioner_group'
5
+
6
+ module Furnish
7
+ #
8
+ # This is a scheduler for provisioners. It can run in parallel or serial mode,
9
+ # and is dependency-based, that is, it will only schedule items for execution
10
+ # which have all their dependencies satisfied and items that haven't will wait
11
+ # to execute until that happens.
12
+ #
13
+ class Scheduler
14
+
15
+ include Furnish::Logger::Mixins
16
+
17
+ #
18
+ # Access the VM object.
19
+ #
20
+ attr_reader :vm
21
+
22
+ #
23
+ # Turn serial mode on (off by default). This forces the scheduler to execute
24
+ # every provision in order, even if it could handle multiple provisions at
25
+ # the same time.
26
+ #
27
+ attr_accessor :serial
28
+
29
+ ##
30
+ #
31
+ # Ignore exceptions while deprovisioning. Default is false.
32
+ #
33
+ attr_accessor :force_deprovision
34
+
35
+ #
36
+ # Instantiate the Scheduler.
37
+ #
38
+ def initialize
39
+ @force_deprovision = false
40
+ @solved_mutex = Mutex.new
41
+ @serial = false
42
+ @solver_thread = nil
43
+ @working_threads = { }
44
+ @queue = Queue.new
45
+ @vm = Furnish::VM.new
46
+ end
47
+
48
+ #
49
+ # Ask the scheduler if it's running. Returns nil in serial mode.
50
+ #
51
+ # If there's an exception waiting and the scheduler has stopped, it will be
52
+ # raised here.
53
+ #
54
+ def running?
55
+ return nil if @serial
56
+ return nil unless @solver_thread
57
+ if @solver_thread.alive?
58
+ return true
59
+ else
60
+ # XXX if there's an exception to be raised, it'll happen here.
61
+ @solver_thread.join
62
+ return nil
63
+ end
64
+ end
65
+
66
+ #
67
+ # Schedule a group of VMs for provision. This takes a group name, which is a
68
+ # string, an array of provisioner objects, and a list of string dependencies.
69
+ # If anything in the dependencies list hasn't been pre-declared, it refuses
70
+ # to continue.
71
+ #
72
+ # This method will return nil if the server group is already provisioned.
73
+ #
74
+ def schedule_provision(group_name, provisioners, dependencies=[])
75
+ group = Furnish::ProvisionerGroup.new(provisioners, group_name, dependencies)
76
+ schedule_provisioner_group(group)
77
+ end
78
+
79
+ #
80
+ # Schedule a provision with a Furnish::ProvisionerGroup. Works exactly like
81
+ # Furnish::Scheduler#schedule_provision otherwise.
82
+ #
83
+ def schedule_provisioner_group(group)
84
+ return nil if vm.groups[group.name]
85
+
86
+ vm.groups[group.name] = group
87
+
88
+ unless group.dependencies.all? { |x| vm.groups.has_key?(x) }
89
+ raise "One of your dependencies for #{group.name} has not been pre-declared. Cannot continue"
90
+ end
91
+
92
+ vm.dependencies[group.name] = group.dependencies
93
+
94
+ vm.sync_waiters do |waiters|
95
+ waiters.add(group.name)
96
+ end
97
+ end
98
+
99
+ #
100
+ # Sleep until this list of dependencies are resolved. In parallel mode, will
101
+ # raise if an exception occurred while waiting for these resources. In
102
+ # serial mode, wait_for just returns nil.
103
+ #
104
+ def wait_for(*dependencies)
105
+ return nil if @serial
106
+ return nil if dependencies.empty?
107
+
108
+ dep_set = Set[*dependencies]
109
+
110
+ until dep_set & vm.solved == dep_set
111
+ sleep 0.1
112
+ @solver_thread.join unless @solver_thread.alive?
113
+ end
114
+ end
115
+
116
+ #
117
+ # Start the scheduler. In serial mode this call will block until the whole
118
+ # dependency graph is satisfied, or one of the provisions fails, at which
119
+ # point an exception will be raised. In parallel mode, this call completes
120
+ # immediately, and you should use #wait_for to control main thread flow,
121
+ # and #running? and #stop to control and monitor the threads this class
122
+ # manages.
123
+ #
124
+ # This call also installs a SIGINFO (Ctrl+T in the terminal on macs) and
125
+ # SIGUSR2 handler which can be used to get information on the status of
126
+ # what's solved and what's working. You can disable this functionality by
127
+ # passing `false` as the first argument.
128
+ #
129
+ def run(install_handler=true)
130
+ # short circuit if we're not serial and already running
131
+ return if @solver_thread and !@serial
132
+
133
+ if install_handler
134
+ handler = lambda do |*args|
135
+ Furnish.logger.puts ["solved:", vm.solved.to_a].inspect
136
+ Furnish.logger.puts ["working:", vm.working.to_a].inspect
137
+ Furnish.logger.puts ["waiting:", vm.waiters.to_a].inspect
138
+ end
139
+
140
+ %w[USR2 INFO].each { |sig| trap(sig, &handler) if Signal.list[sig] }
141
+ end
142
+
143
+ if @serial
144
+ service_resolved_waiters
145
+ queue_loop
146
+ else
147
+ @solver_thread = Thread.new do
148
+ with_timeout(false) { service_resolved_waiters }
149
+ queue_loop
150
+ end
151
+ end
152
+ end
153
+
154
+ #
155
+ # Instructs the scheduler to stop. Note that this is not an interrupt, and
156
+ # the queue will still be exhausted before terminating.
157
+ #
158
+ # It is a good idea to check #running? before calling this to ensure the
159
+ # scheduler did not halt with an exception.
160
+ #
161
+ def stop
162
+ if @serial
163
+ @queue << nil
164
+ else
165
+ @working_threads.values.map { |v| v.join rescue nil }
166
+ if @solver_thread and @solver_thread.alive?
167
+ @queue << nil
168
+ sleep 0.1 until @queue.empty?
169
+ @solver_thread.kill
170
+ end
171
+
172
+ @solver_thread = nil
173
+ end
174
+ end
175
+
176
+ #
177
+ # Teardown a single group -- modifies the solved formula. Be careful to
178
+ # resupply dependencies if you use this, as nothing will resolve until you
179
+ # resupply it.
180
+ #
181
+ # This takes an optional argument to wait for the group to be solved before
182
+ # attempting to tear it down. Setting this to false effectively says, "I know
183
+ # what I'm doing", and you should feel bad if you file an issue because you
184
+ # supplied it.
185
+ #
186
+ # If #force_provision is true, failed shutdowns from provisioners will not
187
+ # halt the deprovisioning process.
188
+ #
189
+ def teardown_group(group_name, wait=true)
190
+ wait_for(group_name) if wait
191
+
192
+ dependent_items = vm.dependencies.partition { |k,v| v.include?(group_name) }.first.map(&:first)
193
+
194
+ if_debug do
195
+ if dependent_items.length > 0
196
+ puts "Trying to terminate #{group_name}, found #{dependent_items.inspect} depending on it"
197
+ end
198
+ end
199
+
200
+ @solved_mutex.synchronize do
201
+ dependent_and_working = @working_threads.keys & dependent_items
202
+
203
+ if dependent_and_working.count > 0
204
+ if_debug do
205
+ puts "#{dependent_and_working.inspect} are depending on #{group_name}, which you are trying to deprovision."
206
+ puts "We can't resolve this problem for you, and future converges may fail during this run that would otherwise work."
207
+ puts "Consider using wait_for to better control the dependencies, or turning serial provisioning on."
208
+ end
209
+ end
210
+
211
+ deprovision_group(group_name)
212
+ end
213
+
214
+ end
215
+
216
+ #
217
+ # Instruct all provisioners except ones in the exception list to tear down.
218
+ # Calls #stop as its first action.
219
+ #
220
+ # This is always done serially. For sanity.
221
+ #
222
+ # If #force_provision is true, failed shutdowns from provisioners will not
223
+ # halt the deprovisioning process.
224
+ #
225
+ def teardown(exceptions=[])
226
+ stop
227
+
228
+ (vm.groups.keys.to_set - exceptions.to_set).each do |group_name|
229
+ deprovision_group(group_name) # clean this after everything finishes
230
+ end
231
+ end
232
+
233
+ #
234
+ # Performs the deprovision of a group by replaying its provision strategy
235
+ # backwards and applying the #shutdown method instead of the #startup method.
236
+ # Removes it from the various state tables if true is set as the second
237
+ # argument, which is the default.
238
+ #
239
+ # While this is a part of the public API, you should probably use #teardown
240
+ # or #teardown_group instead of this method, as they have better error
241
+ # handling and semantics. This "just does it".
242
+ #
243
+ def deprovision_group(group_name, clean_state=true)
244
+ shutdown(group_name)
245
+ delete_group(group_name) if clean_state
246
+ end
247
+
248
+ #--
249
+ #
250
+ # END OF PUBLIC API
251
+ #
252
+ #++
253
+
254
+ protected
255
+
256
+ #
257
+ # Helper method for scheduling. Wraps items in a timeout and immediately
258
+ # checks all running workers for exceptions, which are immediately bubbled up
259
+ # if there are any. If do_loop is true, it will retry the timeout.
260
+ #
261
+ def with_timeout(do_loop=true)
262
+ Timeout.timeout(1) do
263
+ dead_working = @working_threads.values.reject(&:alive?)
264
+ if dead_working.size > 0
265
+ dead_working.map(&:join)
266
+ end
267
+
268
+ yield
269
+ end
270
+ rescue TimeoutError
271
+ retry if do_loop
272
+ end
273
+
274
+ #
275
+ # Consume the queue. Runs until a nil enters the queue, unless in serial
276
+ # mode, where it will terminate when the queue is empty.
277
+ #
278
+ def queue_loop
279
+ run = true
280
+
281
+ while run
282
+ service_resolved_waiters
283
+
284
+ ready = []
285
+
286
+ if @queue.empty?
287
+ if @serial
288
+ return
289
+ else
290
+ with_timeout do
291
+ # this is where most of the execution time is spent, so ensure
292
+ # waiters get considered here.
293
+ service_resolved_waiters
294
+ ready << @queue.shift
295
+ end
296
+ end
297
+ end
298
+
299
+ while !@queue.empty?
300
+ ready << @queue.shift
301
+ end
302
+
303
+ ready.each do |r|
304
+ if r
305
+ @solved_mutex.synchronize do
306
+ vm.solved.add(r)
307
+ @working_threads.delete(r)
308
+ vm.working.delete(r)
309
+ end
310
+ else
311
+ run = false
312
+ end
313
+ end
314
+ end
315
+ end
316
+
317
+ #
318
+ # Helper method to manage waiters based on solved dependencies.
319
+ #
320
+ def resolve_waiters
321
+ vm.sync_waiters do |waiters|
322
+ waiters.replace(waiters.to_set - (@working_threads.keys.to_set + vm.solved.to_set))
323
+ end
324
+ end
325
+
326
+ #
327
+ # Predicate to determine all of a groups dependencies are in the solved set.
328
+ #
329
+ def dependencies_solved?(group_name)
330
+ (vm.solved.to_set & vm.dependencies[group_name]) == vm.dependencies[group_name]
331
+ end
332
+
333
+ #
334
+ # Fetch the ProvisionerGroup and start it.
335
+ #
336
+ def startup(group_name)
337
+ provisioner = vm.groups[group_name]
338
+
339
+ # FIXME maybe a way to specify initial args?
340
+ args = nil
341
+ provisioner.startup
342
+ @queue << group_name
343
+ end
344
+
345
+ #
346
+ # Similar to #startup -- just a shim to talk to a specific ProvisionerGroup
347
+ #
348
+ def shutdown(group_name)
349
+ provisioner = vm.groups[group_name]
350
+
351
+ # if we can't find the provisioner, we probably got asked to clean up
352
+ # something we never scheduled. Just ignore that.
353
+ if provisioner and can_deprovision?(group_name)
354
+ provisioner.shutdown(@force_deprovision)
355
+ end
356
+ end
357
+
358
+ #
359
+ # This method determines what 'waiters', or provisioners that cannot
360
+ # provision yet because of unresolved dependencies, can be executed.
361
+ #
362
+ def service_resolved_waiters
363
+ resolve_waiters
364
+
365
+ vm.sync_waiters do |waiters|
366
+ waiters.each do |group_name|
367
+ if dependencies_solved?(group_name)
368
+ if_debug do
369
+ puts "Provisioning #{group_name}"
370
+ end
371
+
372
+ vm.working.add(group_name)
373
+
374
+ if @serial
375
+ # HACK: just give the working check something that will always work.
376
+ # Probably should just mock it.
377
+ @working_threads[group_name] = Thread.new { sleep }
378
+ startup(group_name)
379
+ else
380
+ @working_threads[group_name] = Thread.new { startup(group_name) }
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
386
+
387
+ #
388
+ # Predicate to determine if a provisioner group can be shutdown.
389
+ #
390
+ def can_deprovision?(group_name)
391
+ ((vm.solved.to_set + vm.working.to_set).include?(group_name) or @force_deprovision)
392
+ end
393
+
394
+ #
395
+ # Wipes the ProvisionerGroup out of the scheduler's state, and terminates
396
+ # any threads managing it.
397
+ #
398
+ def delete_group(group_name)
399
+ vm.solved.delete(group_name)
400
+ vm.sync_waiters do |waiters|
401
+ waiters.delete(group_name)
402
+ end
403
+ @working_threads[group_name].kill rescue nil
404
+ @working_threads.delete(group_name)
405
+ vm.working.delete(group_name)
406
+ vm.dependencies.delete(group_name)
407
+ vm.groups.delete(group_name)
408
+ end
409
+ end
410
+ end
@@ -0,0 +1,4 @@
1
+ module Furnish
2
+ # The current version of Furnish.
3
+ VERSION = "0.0.1"
4
+ end
data/lib/furnish/vm.rb ADDED
@@ -0,0 +1,39 @@
1
+ module Furnish
2
+ #
3
+ # This class mainly exists to track the run state of the Scheduler, and is kept
4
+ # simple. The attributes delegate to Palsy instances.
5
+ #
6
+ class VM
7
+ # the vm groups and their provisioning lists.
8
+ attr_reader :groups
9
+ # the dependencies that each vm group depends on
10
+ attr_reader :dependencies
11
+ # the set of provisioned (solved) groups
12
+ attr_reader :solved
13
+ # the set of provisioning (working) groups
14
+ attr_reader :working
15
+ # the set of groups waiting to be provisioned.
16
+ attr_reader :waiters
17
+
18
+ #
19
+ # Create a new VM object. Should only happen in the Scheduler.
20
+ #
21
+ def initialize
22
+ @groups = Palsy::Map.new('vm_groups', 'provisioner_group')
23
+ @dependencies = Palsy::Map.new('vm_groups', 'dependency_group')
24
+ @solved = Palsy::Set.new('vm_scheduler', 'provisioned')
25
+ @working = Palsy::Set.new('vm_scheduler', 'working')
26
+ @waiters = Palsy::Set.new('vm_scheduler', 'waiters')
27
+ @waiters_mutex = Mutex.new
28
+ end
29
+
30
+ #
31
+ # Helper to deal with waiters in a synchronous way.
32
+ #
33
+ def sync_waiters
34
+ @waiters_mutex.synchronize do
35
+ yield @waiters
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/furnish.rb ADDED
@@ -0,0 +1,51 @@
1
+ require 'palsy'
2
+ require 'furnish/version'
3
+ require 'furnish/logger'
4
+ require 'furnish/scheduler'
5
+
6
+ #
7
+ # Furnish is a scheduling system that has a massive readme which explains what
8
+ # it does here:
9
+ #
10
+ # https://github.com/erikh/furnish
11
+ #
12
+ # You may also wish to read the Furnish::Scheduler, Furnish::Logger, and
13
+ # Furnish::ProvisionerGroup documentation to learn more about it.
14
+ #
15
+ module Furnish
16
+ #
17
+ # Initialize Furnish. The path given is to a SQLite 3 database file that it
18
+ # will create for you.
19
+ #
20
+ def self.init(database_file)
21
+ Palsy.change_db(database_file)
22
+ end
23
+
24
+ #
25
+ # Access the logger (Furnish::Logger) or override it if it does not already
26
+ # exist. In the latter case, Furnish#logger= might be more reliable.
27
+ #
28
+ # The default logger is pointed at standard error and has a debug level of 0.
29
+ #
30
+ def self.logger(io=$stderr, debug_level=0)
31
+ return @logger if @logger
32
+ @logger ||= Furnish::Logger.new(io, debug_level)
33
+ end
34
+
35
+ #
36
+ # Set the logger. This is expected to have an interface akin to
37
+ # Furnish::Logger, it's not the same as ruby's Logger.
38
+ #
39
+ def self.logger=(logger)
40
+ @logger = logger
41
+ end
42
+
43
+ #
44
+ # Shutdown Furnish by closing its state file. Furnish::Scheduler objects and
45
+ # the threads it spawns must be stopped already, otherwise you're going to
46
+ # have a bad day.
47
+ #
48
+ def self.shutdown
49
+ Palsy.instance.close
50
+ end
51
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,15 @@
1
+ require 'bundler/setup'
2
+
3
+ if ENV["COVERAGE"]
4
+ require 'simplecov'
5
+ SimpleCov.start do
6
+ add_filter '/test/'
7
+ end
8
+ end
9
+
10
+ require 'minitest/unit'
11
+ require 'tempfile'
12
+ require 'furnish'
13
+ require 'mt_cases'
14
+
15
+ require 'minitest/autorun'