testbot 0.2.6
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/CHANGELOG +62 -0
- data/Gemfile +12 -0
- data/README.markdown +128 -0
- data/bin/testbot +59 -0
- data/lib/adapters/adapter.rb +22 -0
- data/lib/adapters/cucumber_adapter.rb +31 -0
- data/lib/adapters/rspec_adapter.rb +31 -0
- data/lib/adapters/test_unit_adapter.rb +31 -0
- data/lib/generators/testbot/templates/testbot.rake.erb +35 -0
- data/lib/generators/testbot/templates/testbot.yml.erb +40 -0
- data/lib/generators/testbot/testbot_generator.rb +15 -0
- data/lib/new_runner.rb +30 -0
- data/lib/railtie.rb +10 -0
- data/lib/requester.rb +147 -0
- data/lib/runner.rb +233 -0
- data/lib/server.rb +72 -0
- data/lib/server/build.rb +29 -0
- data/lib/server/db.rb +44 -0
- data/lib/server/group.rb +44 -0
- data/lib/server/job.rb +41 -0
- data/lib/server/runner.rb +38 -0
- data/lib/shared/simple_daemonize.rb +19 -0
- data/lib/shared/ssh_tunnel.rb +36 -0
- data/lib/tasks/testbot.rake +32 -0
- data/lib/testbot.rb +126 -0
- data/testbot.gemspec +27 -0
- metadata +221 -0
data/lib/server/build.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
class Build < Sequel::Model
|
2
|
+
|
3
|
+
def self.create_and_build_jobs(hash)
|
4
|
+
hash["jruby"] = (hash["jruby"] == "true") ? 1 : 0
|
5
|
+
build = create(hash.reject { |k, v| k == 'available_runner_usage' })
|
6
|
+
build.create_jobs!(hash['available_runner_usage'])
|
7
|
+
build
|
8
|
+
end
|
9
|
+
|
10
|
+
def create_jobs!(available_runner_usage)
|
11
|
+
groups = Group.build(self[:files].split, self[:sizes].split.map { |size| size.to_i },
|
12
|
+
Runner.total_instances.to_f * (available_runner_usage.to_i / 100.0), self[:type])
|
13
|
+
groups.each do |group|
|
14
|
+
Job.create(:files => group.join(' '),
|
15
|
+
:root => self[:root],
|
16
|
+
:project => self[:project],
|
17
|
+
:type => self[:type],
|
18
|
+
:requester_mac => self[:requester_mac],
|
19
|
+
:build_id => self[:id],
|
20
|
+
:jruby => self[:jruby])
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def destroy
|
25
|
+
Job.filter([ 'build_id = ?', self[:id] ]).each { |job| job.destroy }
|
26
|
+
super
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
data/lib/server/db.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
|
3
|
+
DB = Sequel.sqlite
|
4
|
+
|
5
|
+
DB.create_table :builds do
|
6
|
+
primary_key :id
|
7
|
+
String :files
|
8
|
+
String :sizes
|
9
|
+
String :results, :default => ''
|
10
|
+
String :root
|
11
|
+
String :project
|
12
|
+
String :type
|
13
|
+
String :requester_mac
|
14
|
+
Integer :jruby
|
15
|
+
Boolean :done, :default => false
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
DB.create_table :jobs do
|
20
|
+
primary_key :id
|
21
|
+
String :files
|
22
|
+
String :result
|
23
|
+
String :root
|
24
|
+
String :project
|
25
|
+
String :type
|
26
|
+
String :requester_mac
|
27
|
+
Integer :jruby
|
28
|
+
Integer :build_id
|
29
|
+
Integer :taken_by_id
|
30
|
+
Datetime :taken_at, :default => nil
|
31
|
+
end
|
32
|
+
|
33
|
+
DB.create_table :runners do
|
34
|
+
primary_key :id
|
35
|
+
String :ip
|
36
|
+
String :hostname
|
37
|
+
String :uid
|
38
|
+
String :username
|
39
|
+
String :version
|
40
|
+
Integer :idle_instances
|
41
|
+
Integer :max_instances
|
42
|
+
Datetime :last_seen_at
|
43
|
+
end
|
44
|
+
|
data/lib/server/group.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
class Group
|
4
|
+
|
5
|
+
DEFAULT = nil
|
6
|
+
|
7
|
+
def self.build(files, sizes, instance_count, type)
|
8
|
+
tests_with_sizes = slow_tests_first(map_files_and_sizes(files, sizes))
|
9
|
+
|
10
|
+
groups = []
|
11
|
+
current_group, current_size = 0, 0
|
12
|
+
tests_with_sizes.each do |test, size|
|
13
|
+
# inserts into next group if current is full and we are not in the last group
|
14
|
+
if (0.5*size + current_size) > group_size(tests_with_sizes, instance_count) and instance_count > current_group + 1
|
15
|
+
current_size = size
|
16
|
+
current_group += 1
|
17
|
+
else
|
18
|
+
current_size += size
|
19
|
+
end
|
20
|
+
groups[current_group] ||= []
|
21
|
+
groups[current_group] << test
|
22
|
+
end
|
23
|
+
|
24
|
+
groups.compact
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def self.group_size(tests_with_sizes, group_count)
|
30
|
+
total = tests_with_sizes.inject(0) { |sum, test| sum += test[1] }
|
31
|
+
total / group_count.to_f
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.map_files_and_sizes(files, sizes)
|
35
|
+
list = []
|
36
|
+
files.each_with_index { |file, i| list << [ file, sizes[i] ] }
|
37
|
+
list
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.slow_tests_first(tests)
|
41
|
+
tests.sort_by { |test, time| time.to_i }.reverse
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
data/lib/server/job.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'db.rb') unless defined?(DB)
|
2
|
+
|
3
|
+
class Job < Sequel::Model
|
4
|
+
def update(hash)
|
5
|
+
super(hash)
|
6
|
+
if build = Build.find([ "id = ?", self[:build_id] ])
|
7
|
+
done = Job.filter([ "result IS NULL AND build_id = ?", self[:build_id] ]).count == 0
|
8
|
+
build.update(:results => build[:results].to_s + hash[:result].to_s,
|
9
|
+
:done => done)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.next(params, remove_addr)
|
14
|
+
clean_params = params.reject { |k, v| [ "requester_mac", "no_jruby" ].include?(k) }
|
15
|
+
runner = Runner.record! clean_params.merge({ :ip => remove_addr, :last_seen_at => Time.now })
|
16
|
+
return unless Server.valid_version?(params[:version])
|
17
|
+
[ next_job_query(params["requester_mac"], params["no_jruby"]).first, runner ]
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def self.next_job_query(requester_mac, no_jruby)
|
23
|
+
release_jobs_taken_by_missing_runners!
|
24
|
+
query = Job.filter("taken_at IS NULL").order("Random()".lit)
|
25
|
+
filters = []
|
26
|
+
filters << "requester_mac = '#{requester_mac}'" if requester_mac
|
27
|
+
filters << "jruby != 1" if no_jruby
|
28
|
+
if filters.empty?
|
29
|
+
query
|
30
|
+
else
|
31
|
+
query.filter(filters.join(' AND '))
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.release_jobs_taken_by_missing_runners!
|
36
|
+
missing_runners = Runner.filter([ "last_seen_at < ?", (Time.now - Runner.timeout) ])
|
37
|
+
missing_runners.each { |r|
|
38
|
+
Job.filter(:taken_by_id => r[:id]).update(:taken_at => nil)
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'db.rb') unless defined?(DB)
|
2
|
+
|
3
|
+
class Runner < Sequel::Model
|
4
|
+
|
5
|
+
def self.record!(hash)
|
6
|
+
create_or_update_by_mac!(hash)
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.create_or_update_by_mac!(hash)
|
10
|
+
if (runner = find(:uid => hash[:uid]))
|
11
|
+
runner.update hash
|
12
|
+
else
|
13
|
+
Runner.create hash
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.timeout
|
18
|
+
10
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.find_all_outdated
|
22
|
+
DB[:runners].filter("version != ? OR version IS NULL", Testbot::VERSION)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.find_all_available
|
26
|
+
DB[:runners].filter("version = ? AND last_seen_at > ?", Testbot::VERSION, Time.now - Runner.timeout)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.available_instances
|
30
|
+
find_all_available.inject(0) { |sum, r| r[:idle_instances] + sum }
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.total_instances
|
34
|
+
return 1 if ENV['INTEGRATION_TEST']
|
35
|
+
find_all_available.inject(0) { |sum, r| r[:max_instances] + sum }
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class SimpleDaemonize
|
2
|
+
|
3
|
+
def self.start(proc, pid_path)
|
4
|
+
pid = fork {
|
5
|
+
STDOUT.reopen "/dev/null"
|
6
|
+
proc.call
|
7
|
+
}
|
8
|
+
|
9
|
+
File.open(pid_path, 'w') { |file| file.write(pid) }
|
10
|
+
pid
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.stop(pid_path)
|
14
|
+
return unless File.exists?(pid_path)
|
15
|
+
system "kill #{File.read(pid_path)} &> /dev/null"
|
16
|
+
system "rm #{pid_path} &> /dev/null"
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'net/ssh'
|
3
|
+
|
4
|
+
class SSHTunnel
|
5
|
+
|
6
|
+
def initialize(host, user, local_port = 2288)
|
7
|
+
@host, @user, @local_port = host, user, local_port
|
8
|
+
end
|
9
|
+
|
10
|
+
def open
|
11
|
+
connect
|
12
|
+
|
13
|
+
start_time = Time.now
|
14
|
+
while true
|
15
|
+
break if @up
|
16
|
+
sleep 0.5
|
17
|
+
|
18
|
+
if Time.now - start_time > 5
|
19
|
+
puts "SSH connection failed, trying again..."
|
20
|
+
start_time = Time.now
|
21
|
+
connect
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def connect
|
27
|
+
@thread.kill! if @thread
|
28
|
+
@thread = Thread.new do
|
29
|
+
Net::SSH.start(@host, @user, { :timeout => 1 }) do |ssh|
|
30
|
+
ssh.forward.local(@local_port, 'localhost', Testbot::SERVER_PORT)
|
31
|
+
ssh.loop { @up = true }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../adapters/adapter'
|
2
|
+
|
3
|
+
namespace :testbot do
|
4
|
+
|
5
|
+
def run_and_show_results(adapter, custom_path)
|
6
|
+
Rake::Task["testbot:before_request"].invoke
|
7
|
+
|
8
|
+
require File.join(File.dirname(__FILE__), '..', "requester.rb")
|
9
|
+
requester = Requester.create_by_config("#{Rails.root}/config/testbot.yml")
|
10
|
+
|
11
|
+
puts "Running #{adapter.pluralized}..."
|
12
|
+
start_time = Time.now
|
13
|
+
|
14
|
+
path = custom_path ? "#{adapter.base_path}/#{custom_path}" : adapter.base_path
|
15
|
+
success = requester.run_tests(adapter, path)
|
16
|
+
|
17
|
+
puts
|
18
|
+
puts requester.result_lines.join("\n")
|
19
|
+
puts
|
20
|
+
puts "Finished in #{Time.now - start_time} seconds."
|
21
|
+
success
|
22
|
+
end
|
23
|
+
|
24
|
+
Adapter.all.each do |adapter|
|
25
|
+
|
26
|
+
desc "Run the #{adapter.name} tests using testbot"
|
27
|
+
task adapter.type, :custom_path do |_, args|
|
28
|
+
exit 1 unless run_and_show_results(adapter, args[:custom_path])
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
data/lib/testbot.rb
ADDED
@@ -0,0 +1,126 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), '/shared/simple_daemonize')
|
2
|
+
require File.join(File.dirname(__FILE__), '/adapters/adapter')
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
module Testbot
|
6
|
+
require 'railtie' if defined?(Rails)
|
7
|
+
|
8
|
+
VERSION = "0.2.6"
|
9
|
+
SERVER_PID = "/tmp/testbot_server.pid"
|
10
|
+
RUNNER_PID = "/tmp/testbot_runner.pid"
|
11
|
+
DEFAULT_WORKING_DIR = "/tmp/testbot"
|
12
|
+
DEFAULT_SERVER_PATH = "/tmp/testbot/#{ENV['USER']}"
|
13
|
+
DEFAULT_USER = "testbot"
|
14
|
+
DEFAULT_PROJECT = "project"
|
15
|
+
DEFAULT_RUNNER_USAGE = "100%"
|
16
|
+
SERVER_PORT = ENV['INTEGRATION_TEST'] ? 22880 : 2288
|
17
|
+
|
18
|
+
class CLI
|
19
|
+
|
20
|
+
def self.run(argv)
|
21
|
+
return false if argv == []
|
22
|
+
opts = parse_args(argv)
|
23
|
+
|
24
|
+
if opts[:help]
|
25
|
+
return false
|
26
|
+
elsif opts[:version]
|
27
|
+
puts "Testbot #{Testbot::VERSION}"
|
28
|
+
elsif [ true, 'run', 'start' ].include?(opts[:server])
|
29
|
+
start_server(opts[:server])
|
30
|
+
elsif opts[:server] == 'stop'
|
31
|
+
stop('server', Testbot::SERVER_PID)
|
32
|
+
elsif [ true, 'run', 'start' ].include?(opts[:runner])
|
33
|
+
require File.join(File.dirname(__FILE__), '/runner')
|
34
|
+
return false unless valid_runner_opts?(opts)
|
35
|
+
start_runner(opts)
|
36
|
+
elsif opts[:runner] == 'stop'
|
37
|
+
stop('runner', Testbot::RUNNER_PID)
|
38
|
+
elsif adapter = Adapter.all.find { |adapter| opts[adapter.type.to_sym] }
|
39
|
+
require File.join(File.dirname(__FILE__), '/requester')
|
40
|
+
start_requester(opts, adapter)
|
41
|
+
end
|
42
|
+
|
43
|
+
true
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.parse_args(argv)
|
47
|
+
last_setter = nil
|
48
|
+
hash = {}
|
49
|
+
str = ''
|
50
|
+
argv.each_with_index do |arg, i|
|
51
|
+
if arg.include?('--')
|
52
|
+
str = ''
|
53
|
+
last_setter = arg.split('--').last.to_sym
|
54
|
+
hash[last_setter] = true if (i == argv.size - 1) || argv[i+1].include?('--')
|
55
|
+
else
|
56
|
+
str += ' ' + arg
|
57
|
+
hash[last_setter] = str.strip
|
58
|
+
end
|
59
|
+
end
|
60
|
+
hash
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.start_runner(opts)
|
64
|
+
stop('runner', Testbot::RUNNER_PID)
|
65
|
+
|
66
|
+
proc = lambda {
|
67
|
+
working_dir = opts[:working_dir] || Testbot::DEFAULT_WORKING_DIR
|
68
|
+
FileUtils.mkdir_p(working_dir)
|
69
|
+
Dir.chdir(working_dir)
|
70
|
+
runner = Runner.new(:server_host => opts[:connect],
|
71
|
+
:auto_update => opts[:auto_update], :max_instances => opts[:cpus],
|
72
|
+
:ssh_tunnel => opts[:ssh_tunnel], :user => opts[:user],
|
73
|
+
:max_jruby_instances => opts[:max_jruby_instances],
|
74
|
+
:jruby_opts => opts[:jruby_opts])
|
75
|
+
runner.run!
|
76
|
+
}
|
77
|
+
|
78
|
+
if opts[:runner] == 'run'
|
79
|
+
proc.call
|
80
|
+
else
|
81
|
+
pid = SimpleDaemonize.start(proc, Testbot::RUNNER_PID)
|
82
|
+
puts "Testbot runner started (pid: #{pid})"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def self.start_server(type)
|
87
|
+
stop('server', Testbot::SERVER_PID)
|
88
|
+
|
89
|
+
if type == 'run'
|
90
|
+
require File.join(File.dirname(__FILE__), '/server')
|
91
|
+
Sinatra::Application.run! :environment => "production"
|
92
|
+
else
|
93
|
+
pid = SimpleDaemonize.start(lambda {
|
94
|
+
ENV['DISABLE_LOGGING'] = "true"
|
95
|
+
require File.join(File.dirname(__FILE__), '/server')
|
96
|
+
Sinatra::Application.run! :environment => "production"
|
97
|
+
}, Testbot::SERVER_PID)
|
98
|
+
puts "Testbot server started (pid: #{pid})"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def self.stop(name, pid)
|
103
|
+
puts "Testbot #{name} stopped" if SimpleDaemonize.stop(pid)
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.start_requester(opts, adapter)
|
107
|
+
requester = Requester.new(:server_host => opts[:connect],
|
108
|
+
:rsync_path => opts[:rsync_path],
|
109
|
+
:rsync_ignores => opts[:rsync_ignores].to_s,
|
110
|
+
:available_runner_usage => nil,
|
111
|
+
:project => opts[:project],
|
112
|
+
:ssh_tunnel => opts[:ssh_tunnel], :server_user => opts[:user])
|
113
|
+
requester.run_tests(adapter, adapter.base_path)
|
114
|
+
end
|
115
|
+
|
116
|
+
def self.valid_runner_opts?(opts)
|
117
|
+
opts[:connect].is_a?(String)
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.lib_path
|
121
|
+
File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
|
122
|
+
end
|
123
|
+
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|
data/testbot.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require File.expand_path("lib/testbot")
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "testbot"
|
7
|
+
s.version = Testbot::VERSION
|
8
|
+
s.authors = ["Joakim Kolsjö"]
|
9
|
+
s.email = ["joakim.kolsjo@gmail.com"]
|
10
|
+
s.homepage = "http://github.com/joakimk/testbot"
|
11
|
+
s.summary = %q{A test distribution tool.}
|
12
|
+
s.description = %q{Testbot is a test distribution tool that works with Rails, RSpec, Test::Unit and Cucumber.}
|
13
|
+
s.bindir = "bin"
|
14
|
+
s.executables = [ "testbot" ]
|
15
|
+
s.files = Dir.glob("lib/**/*") + %w(Gemfile testbot.gemspec CHANGELOG README.markdown bin/testbot)
|
16
|
+
s.add_dependency('sinatra', '=1.0.0') # To be able to use rack 1.0.1 which is compatible with rails 2.
|
17
|
+
s.add_dependency('httparty', '>= 0.6.1')
|
18
|
+
s.add_dependency('macaddr', '>= 1.0.0')
|
19
|
+
s.add_dependency('net-ssh', '>= 2.0.23')
|
20
|
+
s.add_dependency('sequel', '>= 3.16.0')
|
21
|
+
s.add_dependency('sqlite3-ruby', '>= 1.2.5')
|
22
|
+
s.add_dependency('json', '>= 1.4.6')
|
23
|
+
|
24
|
+
# Because sinatra's "disable :logging" does not work with WEBrick.
|
25
|
+
# Also, using a version that builds on ruby 1.9.2.
|
26
|
+
s.add_dependency('mongrel', '1.2.0.pre2')
|
27
|
+
end
|