einhorn 0.5.4 → 0.5.5
Sign up to get free protection for your applications and to get access to all the features.
- data/einhorn.gemspec +4 -5
- data/example/time_server +20 -2
- data/lib/einhorn/command/interface.rb +18 -6
- data/lib/einhorn/command.rb +99 -11
- data/lib/einhorn/compat.rb +36 -0
- data/lib/einhorn/version.rb +1 -1
- data/lib/einhorn/worker_pool.rb +10 -0
- data/lib/einhorn.rb +1 -0
- data/test/unit/einhorn/command/interface.rb +1 -1
- metadata +16 -48
data/einhorn.gemspec
CHANGED
@@ -14,13 +14,12 @@ Gem::Specification.new do |gem|
|
|
14
14
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
15
15
|
gem.name = 'einhorn'
|
16
16
|
gem.require_paths = ['lib']
|
17
|
+
|
17
18
|
gem.add_development_dependency 'rake'
|
18
|
-
gem.add_development_dependency '
|
19
|
-
gem.add_development_dependency '
|
19
|
+
gem.add_development_dependency 'pry'
|
20
|
+
gem.add_development_dependency 'minitest', '< 5.0'
|
21
|
+
gem.add_development_dependency 'mocha', '~> 0.13'
|
20
22
|
gem.add_development_dependency 'chalk-rake'
|
21
23
|
|
22
|
-
gem.add_development_dependency('rake')
|
23
|
-
gem.add_development_dependency('minitest', '< 5.0')
|
24
|
-
gem.add_development_dependency('mocha', '~> 0.13')
|
25
24
|
gem.version = Einhorn::VERSION
|
26
25
|
end
|
data/example/time_server
CHANGED
@@ -15,18 +15,36 @@
|
|
15
15
|
require 'rubygems'
|
16
16
|
require 'einhorn/worker'
|
17
17
|
|
18
|
+
def log(msg)
|
19
|
+
puts "=== [#{$$}] #{msg}"
|
20
|
+
end
|
21
|
+
|
18
22
|
def einhorn_main
|
19
|
-
|
23
|
+
log "Called with ENV['EINHORN_FD_0']: #{ENV['EINHORN_FD_0']}"
|
20
24
|
|
21
25
|
fd_num = Einhorn::Worker.socket!
|
22
26
|
socket = Socket.for_fd(fd_num)
|
23
27
|
|
28
|
+
sleep_before_shutdown = 0
|
29
|
+
sleep_before_ack = 0
|
30
|
+
|
24
31
|
# Came up successfully, so let's set up graceful handler and ACK the
|
25
32
|
# master.
|
26
33
|
Einhorn::Worker.graceful_shutdown do
|
27
|
-
|
34
|
+
if sleep_before_shutdown > 0
|
35
|
+
log "sleeping #{sleep_before_shutdown}s before shutdown"
|
36
|
+
sleep sleep_before_shutdown
|
37
|
+
end
|
38
|
+
log "Goodbye!"
|
28
39
|
exit(0)
|
29
40
|
end
|
41
|
+
|
42
|
+
if sleep_before_ack > 0
|
43
|
+
log "sleeping #{sleep_before_ack}s before ack"
|
44
|
+
sleep sleep_before_ack
|
45
|
+
end
|
46
|
+
log "worker ack"
|
47
|
+
|
30
48
|
Einhorn::Worker.ack!
|
31
49
|
|
32
50
|
# Real work happens here.
|
@@ -162,10 +162,10 @@ module Einhorn::Command
|
|
162
162
|
Einhorn::Command.stop_respawning
|
163
163
|
exit(1)
|
164
164
|
end
|
165
|
-
trap_async("HUP") {Einhorn::Command.
|
165
|
+
trap_async("HUP") {Einhorn::Command.full_upgrade_smooth}
|
166
166
|
trap_async("ALRM") do
|
167
167
|
Einhorn.log_error("Upgrading using SIGALRM is deprecated. Please switch to SIGHUP")
|
168
|
-
Einhorn::Command.
|
168
|
+
Einhorn::Command.full_upgrade_smooth
|
169
169
|
end
|
170
170
|
trap_async("CHLD") {}
|
171
171
|
trap_async("USR2") do
|
@@ -292,7 +292,8 @@ EOF
|
|
292
292
|
# Used by einhornsh
|
293
293
|
command 'ehlo' do |conn, request|
|
294
294
|
<<EOF
|
295
|
-
Welcome #{request['user']}! You are speaking to Einhorn Master Process #{$$}#{Einhorn::State.cmd_name ? " (#{Einhorn::State.cmd_name})" : ''}
|
295
|
+
Welcome, #{request['user']}! You are speaking to Einhorn Master Process #{$$}#{Einhorn::State.cmd_name ? " (#{Einhorn::State.cmd_name})" : ''}.
|
296
|
+
This is Einhorn #{Einhorn::VERSION}.
|
296
297
|
EOF
|
297
298
|
end
|
298
299
|
|
@@ -335,14 +336,25 @@ EOF
|
|
335
336
|
Einhorn::Command.louder
|
336
337
|
end
|
337
338
|
|
338
|
-
command 'upgrade', 'Upgrade all Einhorn workers. This may result in Einhorn reloading its own code as well.' do |conn, request|
|
339
|
+
command 'upgrade', 'Upgrade all Einhorn workers smoothly. This may result in Einhorn reloading its own code as well.' do |conn, request|
|
339
340
|
# send first message directly for old clients that don't support request
|
340
341
|
# ids or subscriptions. Everything else is sent tagged with request id
|
341
342
|
# for new clients.
|
342
|
-
send_message(conn, 'Upgrading, as commanded', request['id'])
|
343
|
+
send_message(conn, 'Upgrading smoothly, as commanded', request['id'])
|
343
344
|
conn.subscribe(:upgrade, request['id'])
|
344
345
|
# If the app is preloaded this doesn't return.
|
345
|
-
Einhorn::Command.
|
346
|
+
Einhorn::Command.full_upgrade_smooth
|
347
|
+
nil
|
348
|
+
end
|
349
|
+
|
350
|
+
command 'upgrade_fleet', 'Upgrade all Einhorn workers a fleet at a time. This may result in Einhorn reloading its own code as well.' do |conn, request|
|
351
|
+
# send first message directly for old clients that don't support request
|
352
|
+
# ids or subscriptions. Everything else is sent tagged with request id
|
353
|
+
# for new clients.
|
354
|
+
send_message(conn, 'Upgrading fleet, as commanded', request['id'])
|
355
|
+
conn.subscribe(:upgrade, request['id'])
|
356
|
+
# If the app is preloaded this doesn't return.
|
357
|
+
Einhorn::Command.full_upgrade_fleet
|
346
358
|
nil
|
347
359
|
end
|
348
360
|
|
data/lib/einhorn/command.rb
CHANGED
@@ -232,6 +232,8 @@ module Einhorn
|
|
232
232
|
Einhorn::Event.close_all_for_worker
|
233
233
|
Einhorn.set_argv(cmd, true)
|
234
234
|
|
235
|
+
reseed_random
|
236
|
+
|
235
237
|
prepare_child_environment
|
236
238
|
einhorn_main
|
237
239
|
end
|
@@ -294,11 +296,53 @@ module Einhorn
|
|
294
296
|
ENV['EINHORN_FDS'] = Einhorn::State.bind_fds.map(&:to_s).join(' ')
|
295
297
|
end
|
296
298
|
|
299
|
+
# Reseed common ruby random number generators.
|
300
|
+
#
|
301
|
+
# OpenSSL::Random uses the PID to reseed after fork, which means that if a
|
302
|
+
# long-lived master process over its lifetime spawns two workers with the
|
303
|
+
# same PID, those workers will start with the same OpenSSL seed.
|
304
|
+
#
|
305
|
+
# Ruby >= 1.9 has a guard against this in SecureRandom, but any direct
|
306
|
+
# users of OpenSSL::Random will still be affected.
|
307
|
+
#
|
308
|
+
# Ruby 1.8 didn't even reseed the default random number generator used by
|
309
|
+
# Kernel#rand in certain releases.
|
310
|
+
#
|
311
|
+
# https://bugs.ruby-lang.org/issues/4579
|
312
|
+
#
|
313
|
+
def self.reseed_random
|
314
|
+
# reseed Kernel#rand
|
315
|
+
srand
|
316
|
+
|
317
|
+
# reseed OpenSSL::Random if it's loaded
|
318
|
+
if defined?(OpenSSL::Random)
|
319
|
+
if defined?(Random)
|
320
|
+
seed = Random.new_seed
|
321
|
+
else
|
322
|
+
# Ruby 1.8
|
323
|
+
seed = rand
|
324
|
+
end
|
325
|
+
OpenSSL::Random.seed(seed.to_s)
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
297
329
|
def self.prepare_child_process
|
298
330
|
Einhorn.renice_self
|
299
331
|
end
|
300
332
|
|
301
|
-
|
333
|
+
# @param options [Hash]
|
334
|
+
#
|
335
|
+
# @option options [Boolean] :smooth (false) Whether to perform a smooth or
|
336
|
+
# fleet upgrade. In a smooth upgrade, bring up new workers and cull old
|
337
|
+
# workers one by one as soon as there is a replacement. In a fleet
|
338
|
+
# upgrade, bring up all the new workers and don't cull any old workers
|
339
|
+
# until they're all up.
|
340
|
+
#
|
341
|
+
def self.full_upgrade(options={})
|
342
|
+
options = {:smooth => false}.merge(options)
|
343
|
+
|
344
|
+
Einhorn::State.smooth_upgrade = options.fetch(:smooth)
|
345
|
+
|
302
346
|
if Einhorn::State.path && !Einhorn::State.reloading_for_preload_upgrade
|
303
347
|
reload_for_preload_upgrade
|
304
348
|
else
|
@@ -306,6 +350,13 @@ module Einhorn
|
|
306
350
|
end
|
307
351
|
end
|
308
352
|
|
353
|
+
def self.full_upgrade_smooth
|
354
|
+
full_upgrade(:smooth => true)
|
355
|
+
end
|
356
|
+
def self.full_upgrade_fleet
|
357
|
+
full_upgrade(:smooth => false)
|
358
|
+
end
|
359
|
+
|
309
360
|
def self.reload_for_preload_upgrade
|
310
361
|
Einhorn::State.reloading_for_preload_upgrade = true
|
311
362
|
reload
|
@@ -316,7 +367,9 @@ module Einhorn
|
|
316
367
|
Einhorn.log_info("Currently upgrading (#{Einhorn::WorkerPool.ack_count} / #{Einhorn::WorkerPool.ack_target} ACKs; bumping version and starting over)...", :upgrade)
|
317
368
|
else
|
318
369
|
Einhorn::State.upgrading = true
|
319
|
-
|
370
|
+
u_type = Einhorn::State.smooth_upgrade ? 'smooth' : 'fleet'
|
371
|
+
Einhorn.log_info("Starting #{u_type} upgrade from version" +
|
372
|
+
" #{Einhorn::State.version}...", :upgrade)
|
320
373
|
end
|
321
374
|
|
322
375
|
# Reset this, since we've just upgraded to a new universe (I'm
|
@@ -326,7 +379,11 @@ module Einhorn
|
|
326
379
|
Einhorn::State.last_upgraded = Time.now
|
327
380
|
|
328
381
|
Einhorn::State.version += 1
|
329
|
-
|
382
|
+
if Einhorn::State.smooth_upgrade
|
383
|
+
replenish_gradually
|
384
|
+
else
|
385
|
+
replenish_immediately
|
386
|
+
end
|
330
387
|
end
|
331
388
|
|
332
389
|
def self.cull
|
@@ -341,9 +398,20 @@ module Einhorn
|
|
341
398
|
end
|
342
399
|
|
343
400
|
old_workers = Einhorn::WorkerPool.old_workers
|
401
|
+
Einhorn.log_debug("#{acked} acked, #{unsignaled} unsignaled, #{target} target, #{old_workers.length} old workers")
|
344
402
|
if !Einhorn::State.upgrading && old_workers.length > 0
|
345
403
|
Einhorn.log_info("Killing off #{old_workers.length} old workers.")
|
346
404
|
signal_all("USR2", old_workers)
|
405
|
+
elsif Einhorn::State.upgrading && Einhorn::State.smooth_upgrade
|
406
|
+
# In a smooth upgrade, kill off old workers one by one when we have
|
407
|
+
# sufficiently many new workers.
|
408
|
+
excess = (old_workers.length + acked) - target
|
409
|
+
if excess > 0
|
410
|
+
Einhorn.log_info("Smooth upgrade: killing off #{excess} old workers.")
|
411
|
+
signal_all("USR2", old_workers.take(excess))
|
412
|
+
elsif excess < 0
|
413
|
+
Einhorn.log_error("Smooth upgrade: somehow excess is #{excess}!")
|
414
|
+
end
|
347
415
|
end
|
348
416
|
|
349
417
|
if unsignaled > target
|
@@ -378,25 +446,45 @@ module Einhorn
|
|
378
446
|
missing.times {spinup}
|
379
447
|
end
|
380
448
|
|
381
|
-
def self.replenish_gradually
|
449
|
+
def self.replenish_gradually(max_unacked=nil)
|
382
450
|
return if Einhorn::TransientState.has_outstanding_spinup_timer
|
383
451
|
return unless Einhorn::WorkerPool.missing_worker_count > 0
|
384
452
|
|
453
|
+
# default to spinning up at most NCPU workers at once
|
454
|
+
unless max_unacked
|
455
|
+
begin
|
456
|
+
@processor_count ||= Einhorn::Compat.processor_count
|
457
|
+
rescue => err
|
458
|
+
Einhorn.log_error(err.inspect)
|
459
|
+
@processor_count = 1
|
460
|
+
end
|
461
|
+
max_unacked = @processor_count
|
462
|
+
end
|
463
|
+
|
464
|
+
if max_unacked <= 0
|
465
|
+
raise ArgumentError.new("max_unacked must be positive")
|
466
|
+
end
|
467
|
+
|
385
468
|
# Exponentially backoff automated spinup if we're just having
|
386
469
|
# things die before ACKing
|
387
470
|
spinup_interval = Einhorn::State.config[:seconds] * (1.5 ** Einhorn::State.consecutive_deaths_before_ack)
|
388
471
|
seconds_ago = (Time.now - Einhorn::State.last_spinup).to_f
|
389
472
|
|
390
473
|
if seconds_ago > spinup_interval
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
Einhorn.log_info("#{msg} (there have been #{Einhorn::State.consecutive_deaths_before_ack} consecutive unacked worker deaths)")
|
474
|
+
unacked = Einhorn::WorkerPool.unacked_unsignaled_modern_workers.length
|
475
|
+
if unacked >= max_unacked
|
476
|
+
Einhorn.log_debug("There are #{unacked} unacked new workers, and max_unacked is #{max_unacked}, so not spinning up a new process")
|
395
477
|
else
|
396
|
-
|
397
|
-
end
|
478
|
+
msg = "Last spinup was #{seconds_ago}s ago, and spinup_interval is #{spinup_interval}s, so spinning up a new process"
|
398
479
|
|
399
|
-
|
480
|
+
if Einhorn::State.consecutive_deaths_before_ack > 0
|
481
|
+
Einhorn.log_info("#{msg} (there have been #{Einhorn::State.consecutive_deaths_before_ack} consecutive unacked worker deaths)")
|
482
|
+
else
|
483
|
+
Einhorn.log_debug(msg)
|
484
|
+
end
|
485
|
+
|
486
|
+
spinup
|
487
|
+
end
|
400
488
|
else
|
401
489
|
Einhorn.log_debug("Last spinup was #{seconds_ago}s ago, and spinup_interval is #{spinup_interval}s, so not spinning up a new process")
|
402
490
|
end
|
data/lib/einhorn/compat.rb
CHANGED
@@ -44,5 +44,41 @@ module Einhorn
|
|
44
44
|
cloexec!(sock, false)
|
45
45
|
sock
|
46
46
|
end
|
47
|
+
|
48
|
+
def self.processor_count
|
49
|
+
# jruby
|
50
|
+
if defined? Java::Java
|
51
|
+
return Java::Java.lang.Runtime.getRuntime.availableProcessors
|
52
|
+
end
|
53
|
+
|
54
|
+
# linux / friends
|
55
|
+
begin
|
56
|
+
return File.read('/proc/cpuinfo').scan(/^processor\s*:/).count
|
57
|
+
rescue Errno::ENOENT
|
58
|
+
end
|
59
|
+
|
60
|
+
# OS X
|
61
|
+
if RUBY_PLATFORM =~ /darwin/
|
62
|
+
return Integer(`sysctl -n hw.logicalcpu`)
|
63
|
+
end
|
64
|
+
|
65
|
+
# windows / friends
|
66
|
+
begin
|
67
|
+
require 'win32ole'
|
68
|
+
rescue LoadError
|
69
|
+
else
|
70
|
+
wmi = WIN32OLE.connect("winmgmts://")
|
71
|
+
wmi.ExecQuery("select * from Win32_ComputerSystem").each do |system|
|
72
|
+
begin
|
73
|
+
processors = system.NumberOfLogicalProcessors
|
74
|
+
rescue
|
75
|
+
processors = 0
|
76
|
+
end
|
77
|
+
return [system.NumberOfProcessors, processors].max
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
raise "Failed to detect number of CPUs"
|
82
|
+
end
|
47
83
|
end
|
48
84
|
end
|
data/lib/einhorn/version.rb
CHANGED
data/lib/einhorn/worker_pool.rb
CHANGED
@@ -52,6 +52,16 @@ module Einhorn
|
|
52
52
|
end.map {|pid, _| pid}
|
53
53
|
end
|
54
54
|
|
55
|
+
def self.unacked_unsignaled_modern_workers_with_state
|
56
|
+
modern_workers_with_state.select {|pid, spec|
|
57
|
+
!spec[:acked] && spec[:signaled].length == 0
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.unacked_unsignaled_modern_workers
|
62
|
+
unacked_unsignaled_modern_workers_with_state.map {|pid, _| pid}
|
63
|
+
end
|
64
|
+
|
55
65
|
# Use the number of modern workers, rather than unsignaled modern
|
56
66
|
# workers. This means if e.g. we do bunch of decs and then incs,
|
57
67
|
# any workers which haven't died yet will count towards our number
|
data/lib/einhorn.rb
CHANGED
@@ -12,7 +12,7 @@ class InterfaceTest < EinhornTestCase
|
|
12
12
|
# Remove trailing newline
|
13
13
|
message = message[0...-1]
|
14
14
|
parsed = YAML.load(URI.unescape(message))
|
15
|
-
parsed['message'] =~ /Welcome gdb/
|
15
|
+
parsed['message'] =~ /Welcome, gdb/
|
16
16
|
end
|
17
17
|
request = {
|
18
18
|
'command' => 'ehlo',
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: einhorn
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.5
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2014-
|
12
|
+
date: 2014-03-25 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rake
|
@@ -28,7 +28,7 @@ dependencies:
|
|
28
28
|
- !ruby/object:Gem::Version
|
29
29
|
version: '0'
|
30
30
|
- !ruby/object:Gem::Dependency
|
31
|
-
name:
|
31
|
+
name: pry
|
32
32
|
requirement: !ruby/object:Gem::Requirement
|
33
33
|
none: false
|
34
34
|
requirements:
|
@@ -44,39 +44,39 @@ dependencies:
|
|
44
44
|
- !ruby/object:Gem::Version
|
45
45
|
version: '0'
|
46
46
|
- !ruby/object:Gem::Dependency
|
47
|
-
name:
|
47
|
+
name: minitest
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
49
49
|
none: false
|
50
50
|
requirements:
|
51
|
-
- -
|
51
|
+
- - <
|
52
52
|
- !ruby/object:Gem::Version
|
53
|
-
version: '0'
|
53
|
+
version: '5.0'
|
54
54
|
type: :development
|
55
55
|
prerelease: false
|
56
56
|
version_requirements: !ruby/object:Gem::Requirement
|
57
57
|
none: false
|
58
58
|
requirements:
|
59
|
-
- -
|
59
|
+
- - <
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version: '0'
|
61
|
+
version: '5.0'
|
62
62
|
- !ruby/object:Gem::Dependency
|
63
|
-
name:
|
63
|
+
name: mocha
|
64
64
|
requirement: !ruby/object:Gem::Requirement
|
65
65
|
none: false
|
66
66
|
requirements:
|
67
|
-
- -
|
67
|
+
- - ~>
|
68
68
|
- !ruby/object:Gem::Version
|
69
|
-
version: '0'
|
69
|
+
version: '0.13'
|
70
70
|
type: :development
|
71
71
|
prerelease: false
|
72
72
|
version_requirements: !ruby/object:Gem::Requirement
|
73
73
|
none: false
|
74
74
|
requirements:
|
75
|
-
- -
|
75
|
+
- - ~>
|
76
76
|
- !ruby/object:Gem::Version
|
77
|
-
version: '0'
|
77
|
+
version: '0.13'
|
78
78
|
- !ruby/object:Gem::Dependency
|
79
|
-
name: rake
|
79
|
+
name: chalk-rake
|
80
80
|
requirement: !ruby/object:Gem::Requirement
|
81
81
|
none: false
|
82
82
|
requirements:
|
@@ -91,38 +91,6 @@ dependencies:
|
|
91
91
|
- - ! '>='
|
92
92
|
- !ruby/object:Gem::Version
|
93
93
|
version: '0'
|
94
|
-
- !ruby/object:Gem::Dependency
|
95
|
-
name: minitest
|
96
|
-
requirement: !ruby/object:Gem::Requirement
|
97
|
-
none: false
|
98
|
-
requirements:
|
99
|
-
- - <
|
100
|
-
- !ruby/object:Gem::Version
|
101
|
-
version: '5.0'
|
102
|
-
type: :development
|
103
|
-
prerelease: false
|
104
|
-
version_requirements: !ruby/object:Gem::Requirement
|
105
|
-
none: false
|
106
|
-
requirements:
|
107
|
-
- - <
|
108
|
-
- !ruby/object:Gem::Version
|
109
|
-
version: '5.0'
|
110
|
-
- !ruby/object:Gem::Dependency
|
111
|
-
name: mocha
|
112
|
-
requirement: !ruby/object:Gem::Requirement
|
113
|
-
none: false
|
114
|
-
requirements:
|
115
|
-
- - ~>
|
116
|
-
- !ruby/object:Gem::Version
|
117
|
-
version: '0.13'
|
118
|
-
type: :development
|
119
|
-
prerelease: false
|
120
|
-
version_requirements: !ruby/object:Gem::Requirement
|
121
|
-
none: false
|
122
|
-
requirements:
|
123
|
-
- - ~>
|
124
|
-
- !ruby/object:Gem::Version
|
125
|
-
version: '0.13'
|
126
94
|
description: Einhorn makes it easy to run multiple instances of an application server,
|
127
95
|
all listening on the same port. You can also seamlessly restart your workers without
|
128
96
|
dropping any requests. Einhorn requires minimal application-level support, making
|
@@ -198,7 +166,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
198
166
|
version: '0'
|
199
167
|
segments:
|
200
168
|
- 0
|
201
|
-
hash: -
|
169
|
+
hash: -690549708583071819
|
202
170
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
203
171
|
none: false
|
204
172
|
requirements:
|
@@ -207,7 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
207
175
|
version: '0'
|
208
176
|
segments:
|
209
177
|
- 0
|
210
|
-
hash: -
|
178
|
+
hash: -690549708583071819
|
211
179
|
requirements: []
|
212
180
|
rubyforge_project:
|
213
181
|
rubygems_version: 1.8.23
|