testbot 0.2.6
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -0,0 +1,15 @@
|
|
1
|
+
class TestbotGenerator < Rails::Generators::Base
|
2
|
+
source_root File.expand_path('../templates', __FILE__)
|
3
|
+
|
4
|
+
class_option :connect, :type => :string, :required => true, :desc => "Which server to use (required)"
|
5
|
+
class_option :project, :type => :string, :default => nil, :desc => "The name of your project (default: #{Testbot::DEFAULT_PROJECT})"
|
6
|
+
class_option :rsync_path, :type => :string, :default => nil, :desc => "Sync path on the server (default: #{Testbot::DEFAULT_SERVER_PATH})"
|
7
|
+
class_option :rsync_ignores, :type => :string, :default => nil, :desc => "Files to rsync_ignores when syncing (default: include all)"
|
8
|
+
class_option :ssh_tunnel, :type => :boolean, :default => nil, :desc => "Use a ssh tunnel"
|
9
|
+
class_option :user, :type => :string, :default => nil, :desc => "Use a custom rsync / ssh tunnel user (default: #{Testbot::DEFAULT_USER})"
|
10
|
+
|
11
|
+
def generate_config
|
12
|
+
template "testbot.yml.erb", "config/testbot.yml"
|
13
|
+
template "testbot.rake.erb", "lib/tasks/testbot.rake"
|
14
|
+
end
|
15
|
+
end
|
data/lib/new_runner.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'httparty'
|
3
|
+
|
4
|
+
class Server
|
5
|
+
include HTTParty
|
6
|
+
end
|
7
|
+
|
8
|
+
class NewRunner
|
9
|
+
|
10
|
+
def self.run_jobs
|
11
|
+
next_job = Server.get('/jobs/next') rescue nil
|
12
|
+
return unless next_job
|
13
|
+
id, root, specs = next_job.split(',')
|
14
|
+
result = run_and_return_results("export RAILS_ENV=test; export RSPEC_COLOR=true; rake testbot:before_run; script/spec -O spec/spec.opts #{specs}")
|
15
|
+
Server.put("/jobs/#{id}", :body => { :result => result })
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.load_config
|
19
|
+
host = YAML.load_file("#{ENV['HOME']}/.testbot_runner.yml")[:server_host]
|
20
|
+
Server.base_uri "http://#{host}:2288"
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# I'd really like to know how to do this better and more testable
|
26
|
+
def self.run_and_return_results(cmd)
|
27
|
+
`#{cmd} 2>&1`
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
data/lib/railtie.rb
ADDED
data/lib/requester.rb
ADDED
@@ -0,0 +1,147 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'httparty'
|
3
|
+
require 'macaddr'
|
4
|
+
require 'ostruct'
|
5
|
+
require File.dirname(__FILE__) + '/shared/ssh_tunnel'
|
6
|
+
require File.dirname(__FILE__) + '/adapters/adapter'
|
7
|
+
require File.expand_path(File.dirname(__FILE__) + '/testbot')
|
8
|
+
|
9
|
+
class Hash
|
10
|
+
def symbolize_keys_without_active_support
|
11
|
+
inject({}) do |options, (key, value)|
|
12
|
+
options[(key.to_sym rescue key) || key] = value
|
13
|
+
options
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class Requester
|
19
|
+
|
20
|
+
attr_reader :config
|
21
|
+
|
22
|
+
def initialize(config = {})
|
23
|
+
config = config.symbolize_keys_without_active_support
|
24
|
+
config[:rsync_path] ||= Testbot::DEFAULT_SERVER_PATH
|
25
|
+
config[:project] ||= Testbot::DEFAULT_PROJECT
|
26
|
+
config[:server_user] ||= Testbot::DEFAULT_USER
|
27
|
+
config[:available_runner_usage] ||= Testbot::DEFAULT_RUNNER_USAGE
|
28
|
+
@config = OpenStruct.new(config)
|
29
|
+
end
|
30
|
+
|
31
|
+
def run_tests(adapter, dir)
|
32
|
+
puts if config.simple_output
|
33
|
+
|
34
|
+
if config.ssh_tunnel
|
35
|
+
SSHTunnel.new(config.server_host, config.server_user, adapter.requester_port).open
|
36
|
+
server_uri = "http://127.0.0.1:#{adapter.requester_port}"
|
37
|
+
else
|
38
|
+
server_uri = "http://#{config.server_host}:#{Testbot::SERVER_PORT}"
|
39
|
+
end
|
40
|
+
|
41
|
+
rsync_ignores = config.rsync_ignores.to_s.split.map { |pattern| "--exclude='#{pattern}'" }.join(' ')
|
42
|
+
system "rsync -az --delete -e ssh #{rsync_ignores} . #{rsync_uri}"
|
43
|
+
|
44
|
+
files = find_tests(adapter, dir)
|
45
|
+
sizes = find_sizes(files)
|
46
|
+
|
47
|
+
build_id = HTTParty.post("#{server_uri}/builds", :body => { :root => root,
|
48
|
+
:type => adapter.type.to_s,
|
49
|
+
:project => config.project,
|
50
|
+
:requester_mac => Mac.addr,
|
51
|
+
:available_runner_usage => config.available_runner_usage,
|
52
|
+
:files => files.join(' '),
|
53
|
+
:sizes => sizes.join(' '),
|
54
|
+
:jruby => jruby? })
|
55
|
+
|
56
|
+
|
57
|
+
last_results_size = 0
|
58
|
+
success = true
|
59
|
+
error_count = 0
|
60
|
+
while true
|
61
|
+
sleep 1
|
62
|
+
|
63
|
+
begin
|
64
|
+
@build = HTTParty.get("#{server_uri}/builds/#{build_id}", :format => :json)
|
65
|
+
next unless @build
|
66
|
+
rescue Exception => ex
|
67
|
+
error_count += 1
|
68
|
+
if error_count > 4
|
69
|
+
puts "Failed to get status: #{ex.message}"
|
70
|
+
error_count = 0
|
71
|
+
end
|
72
|
+
next
|
73
|
+
end
|
74
|
+
|
75
|
+
results = @build['results'][last_results_size..-1]
|
76
|
+
unless results == ''
|
77
|
+
if config.simple_output
|
78
|
+
print results.gsub(/[^\.F]|Finished/, '')
|
79
|
+
STDOUT.flush
|
80
|
+
else
|
81
|
+
puts results
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
last_results_size = @build['results'].size
|
86
|
+
|
87
|
+
success = false if failed_build?(@build)
|
88
|
+
break if @build['done']
|
89
|
+
end
|
90
|
+
|
91
|
+
puts if config.simple_output
|
92
|
+
|
93
|
+
success
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.create_by_config(path)
|
97
|
+
config = YAML.load_file(path)
|
98
|
+
Requester.new(config)
|
99
|
+
end
|
100
|
+
|
101
|
+
def result_lines
|
102
|
+
@build['results'].split("\n").find_all { |line| line_is_result?(line) }.map { |line| line.chomp }
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def root
|
108
|
+
if localhost?
|
109
|
+
config.rsync_path
|
110
|
+
else
|
111
|
+
"#{config.server_user}@#{config.server_host}:#{config.rsync_path}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def rsync_uri
|
116
|
+
localhost? ? config.rsync_path : "#{config.server_user}@#{config.server_host}:#{config.rsync_path}"
|
117
|
+
end
|
118
|
+
|
119
|
+
def localhost?
|
120
|
+
[ '0.0.0.0', 'localhost', '127.0.0.1' ].include?(config.server_host)
|
121
|
+
end
|
122
|
+
|
123
|
+
def failed_build?(build)
|
124
|
+
result_lines.any? { |line| line_is_failure?(line) }
|
125
|
+
end
|
126
|
+
|
127
|
+
def line_is_result?(line)
|
128
|
+
line =~ /\d+ fail/
|
129
|
+
end
|
130
|
+
|
131
|
+
def line_is_failure?(line)
|
132
|
+
line =~ /(\d{2,}|[1-9]) (fail|error)/
|
133
|
+
end
|
134
|
+
|
135
|
+
def find_tests(adapter, dir)
|
136
|
+
Dir["#{dir}/#{adapter.file_pattern}"]
|
137
|
+
end
|
138
|
+
|
139
|
+
def find_sizes(files)
|
140
|
+
files.map { |file| File.stat(file).size }
|
141
|
+
end
|
142
|
+
|
143
|
+
def jruby?
|
144
|
+
RUBY_PLATFORM =~ /java/ || !!ENV['USE_JRUBY']
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
data/lib/runner.rb
ADDED
@@ -0,0 +1,233 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'httparty'
|
3
|
+
require 'macaddr'
|
4
|
+
require 'ostruct'
|
5
|
+
require File.dirname(__FILE__) + '/shared/ssh_tunnel'
|
6
|
+
require File.dirname(__FILE__) + '/adapters/adapter'
|
7
|
+
|
8
|
+
TIME_BETWEEN_POLLS = 1
|
9
|
+
TIME_BETWEEN_PINGS = 5
|
10
|
+
TIME_BETWEEN_VERSION_CHECKS = 60
|
11
|
+
MAX_CPU_USAGE_WHEN_IDLE = 50
|
12
|
+
|
13
|
+
class CPU
|
14
|
+
|
15
|
+
def self.current_usage
|
16
|
+
process_usages = `ps -eo pcpu`
|
17
|
+
total_usage = process_usages.split("\n").inject(0) { |sum, usage| sum += usage.strip.to_f }
|
18
|
+
(total_usage / count).to_i
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.count
|
22
|
+
case RUBY_PLATFORM
|
23
|
+
when /darwin/
|
24
|
+
`hwprefs cpu_count`.to_i
|
25
|
+
when /linux/
|
26
|
+
`cat /proc/cpuinfo | grep processor | wc -l`.to_i
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
class Job
|
33
|
+
attr_reader :root, :project, :requester_mac
|
34
|
+
|
35
|
+
def initialize(runner, id, requester_mac, project, root, type, ruby_interpreter, files)
|
36
|
+
@runner, @id, @requester_mac, @project, @root, @type, @ruby_interpreter, @files =
|
37
|
+
runner, id, requester_mac, project, root, type, ruby_interpreter, files
|
38
|
+
end
|
39
|
+
|
40
|
+
def jruby?
|
41
|
+
@ruby_interpreter == 'jruby'
|
42
|
+
end
|
43
|
+
|
44
|
+
def run(instance)
|
45
|
+
puts "Running job #{@id} from #{@requester_mac}... "
|
46
|
+
test_env_number = (instance == 0) ? '' : instance + 1
|
47
|
+
result = "\n#{`hostname`.chomp}:#{Dir.pwd}\n"
|
48
|
+
base_environment = "export RAILS_ENV=test; export TEST_ENV_NUMBER=#{test_env_number}; cd #{@project};"
|
49
|
+
|
50
|
+
adapter = Adapter.find(@type)
|
51
|
+
result += `#{base_environment} #{adapter.command(ruby_cmd, @files)} 2>&1`
|
52
|
+
|
53
|
+
Server.put("/jobs/#{@id}", :body => { :result => result })
|
54
|
+
puts "Job #{@id} finished."
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def ruby_cmd
|
60
|
+
if @ruby_interpreter == 'jruby' && @runner.config.jruby_opts
|
61
|
+
'jruby ' + @runner.config.jruby_opts
|
62
|
+
else
|
63
|
+
@ruby_interpreter
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class Server
|
69
|
+
include HTTParty
|
70
|
+
end
|
71
|
+
|
72
|
+
class Runner
|
73
|
+
|
74
|
+
def initialize(config)
|
75
|
+
@instances = []
|
76
|
+
@last_requester_mac = nil
|
77
|
+
@last_version_check = Time.now - TIME_BETWEEN_VERSION_CHECKS - 1
|
78
|
+
@config = OpenStruct.new(config)
|
79
|
+
@config.max_instances = @config.max_instances ? @config.max_instances.to_i : CPU.count
|
80
|
+
|
81
|
+
if @config.ssh_tunnel
|
82
|
+
server_uri = "http://127.0.0.1:#{Testbot::SERVER_PORT}"
|
83
|
+
else
|
84
|
+
server_uri = "http://#{@config.server_host}:#{Testbot::SERVER_PORT}"
|
85
|
+
end
|
86
|
+
|
87
|
+
Server.base_uri(server_uri)
|
88
|
+
end
|
89
|
+
|
90
|
+
attr_reader :config
|
91
|
+
|
92
|
+
def run!
|
93
|
+
# Remove legacy instance* and *_rsync|git style folders
|
94
|
+
Dir.entries(".").find_all { |name| name.include?('instance') || name.include?('_rsync') ||
|
95
|
+
name.include?('_git') }.each { |folder|
|
96
|
+
system "rm -rf #{folder}"
|
97
|
+
}
|
98
|
+
|
99
|
+
SSHTunnel.new(@config.server_host, @config.server_user || Testbot::DEFAULT_USER).open if @config.ssh_tunnel
|
100
|
+
while true
|
101
|
+
begin
|
102
|
+
update_uid!
|
103
|
+
start_ping
|
104
|
+
wait_for_jobs
|
105
|
+
rescue Exception => ex
|
106
|
+
break if [ 'SignalException', 'Interrupt' ].include?(ex.class.to_s)
|
107
|
+
puts "The runner crashed, restarting. Error: #{ex.inspect} #{ex.class}"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def update_uid!
|
115
|
+
# When a runner crashes or is restarted it might loose current job info. Because
|
116
|
+
# of this we provide a new unique ID to the server so that it does not wait for
|
117
|
+
# lost jobs to complete.
|
118
|
+
@uid = "#{Time.now.to_i}@#{Mac.addr}"
|
119
|
+
end
|
120
|
+
|
121
|
+
def wait_for_jobs
|
122
|
+
loop do
|
123
|
+
sleep TIME_BETWEEN_POLLS
|
124
|
+
check_for_update if time_for_update?
|
125
|
+
|
126
|
+
# Only get jobs from one requester at a time
|
127
|
+
next_params = base_params
|
128
|
+
if @instances.size > 0
|
129
|
+
next_params.merge!({ :requester_mac => @last_requester_mac })
|
130
|
+
next_params.merge!({ :no_jruby => true }) if max_jruby_instances?
|
131
|
+
else
|
132
|
+
@last_requester_mac = nil
|
133
|
+
end
|
134
|
+
|
135
|
+
# Makes sure all instances are listed as available after a run
|
136
|
+
clear_completed_instances
|
137
|
+
next unless cpu_available?
|
138
|
+
|
139
|
+
next_job = Server.get("/jobs/next", :query => next_params) rescue nil
|
140
|
+
next if next_job == nil
|
141
|
+
|
142
|
+
job = Job.new(*([ self, next_job.split(',') ].flatten))
|
143
|
+
if first_job_from_requester?
|
144
|
+
fetch_code(job)
|
145
|
+
before_run(job) if File.exists?("#{job.project}/lib/tasks/testbot.rake")
|
146
|
+
end
|
147
|
+
|
148
|
+
@instances << [ Thread.new { job.run(free_instance_number) },
|
149
|
+
free_instance_number, job ]
|
150
|
+
@last_requester_mac = job.requester_mac
|
151
|
+
loop do
|
152
|
+
clear_completed_instances
|
153
|
+
break unless max_instances_running?
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def max_jruby_instances?
|
159
|
+
return unless @config.max_jruby_instances
|
160
|
+
@instances.find_all { |thread, n, job| job.jruby? }.size >= @config.max_jruby_instances
|
161
|
+
end
|
162
|
+
|
163
|
+
def fetch_code(job)
|
164
|
+
system "rsync -az --delete -e ssh #{job.root}/ #{job.project}"
|
165
|
+
end
|
166
|
+
|
167
|
+
def before_run(job)
|
168
|
+
system "export RAILS_ENV=test; export TEST_INSTANCES=#{@config.max_instances}; cd #{job.project}; rake testbot:before_run"
|
169
|
+
end
|
170
|
+
|
171
|
+
def first_job_from_requester?
|
172
|
+
@last_requester_mac == nil
|
173
|
+
end
|
174
|
+
|
175
|
+
def cpu_available?
|
176
|
+
@instances.size > 0 || CPU.current_usage < MAX_CPU_USAGE_WHEN_IDLE
|
177
|
+
end
|
178
|
+
|
179
|
+
def time_for_update?
|
180
|
+
time_for_update = ((Time.now - @last_version_check) >= TIME_BETWEEN_VERSION_CHECKS)
|
181
|
+
@last_version_check = Time.now if time_for_update
|
182
|
+
time_for_update
|
183
|
+
end
|
184
|
+
|
185
|
+
def check_for_update
|
186
|
+
return unless @config.auto_update
|
187
|
+
version = Server.get('/version') rescue Testbot::VERSION
|
188
|
+
return unless version != Testbot::VERSION
|
189
|
+
|
190
|
+
successful_install = system "gem install testbot -v #{version}"
|
191
|
+
|
192
|
+
# This closes the process and attempts to re-run the same command.
|
193
|
+
exec "testbot #{ARGV.join(' ')}" if successful_install
|
194
|
+
end
|
195
|
+
|
196
|
+
def ping_params
|
197
|
+
{ :hostname => (@hostname ||= `hostname`.chomp), :max_instances => @config.max_instances,
|
198
|
+
:idle_instances => (@config.max_instances - @instances.size), :username => ENV['USER'] }.merge(base_params)
|
199
|
+
end
|
200
|
+
|
201
|
+
def base_params
|
202
|
+
{ :version => Testbot::VERSION, :uid => @uid }
|
203
|
+
end
|
204
|
+
|
205
|
+
def max_instances_running?
|
206
|
+
@instances.size == @config.max_instances
|
207
|
+
end
|
208
|
+
|
209
|
+
def clear_completed_instances
|
210
|
+
@instances.each_with_index do |data, index|
|
211
|
+
@instances.delete_at(index) if data.first.join(0.25)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def free_instance_number
|
216
|
+
0.upto(@config.max_instances - 1) do |number|
|
217
|
+
return number unless @instances.find { |instance, n, job| n == number }
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def start_ping
|
222
|
+
Thread.new do
|
223
|
+
while true
|
224
|
+
begin
|
225
|
+
Server.get("/runners/ping", :body => ping_params)
|
226
|
+
rescue
|
227
|
+
end
|
228
|
+
sleep TIME_BETWEEN_PINGS
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
end
|
data/lib/server.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'sinatra'
|
3
|
+
require 'yaml'
|
4
|
+
require 'json'
|
5
|
+
require File.join(File.dirname(__FILE__), 'server/job.rb')
|
6
|
+
require File.join(File.dirname(__FILE__), 'server/group.rb') unless defined?(Group)
|
7
|
+
require File.join(File.dirname(__FILE__), 'server/runner.rb')
|
8
|
+
require File.join(File.dirname(__FILE__), 'server/build.rb')
|
9
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'testbot'))
|
10
|
+
|
11
|
+
if ENV['INTEGRATION_TEST']
|
12
|
+
set :port, 22880
|
13
|
+
else
|
14
|
+
set :port, Testbot::SERVER_PORT
|
15
|
+
end
|
16
|
+
|
17
|
+
disable :logging if ENV['DISABLE_LOGGING']
|
18
|
+
|
19
|
+
class Server
|
20
|
+
def self.valid_version?(runner_version)
|
21
|
+
Testbot::VERSION == runner_version
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
post '/builds' do
|
26
|
+
build = Build.create_and_build_jobs(params)[:id].to_s
|
27
|
+
end
|
28
|
+
|
29
|
+
get '/builds/:id' do
|
30
|
+
build = Build.find(:id => params[:id].to_i)
|
31
|
+
build.destroy if build[:done]
|
32
|
+
{ "done" => build[:done], "results" => build[:results] }.to_json
|
33
|
+
end
|
34
|
+
|
35
|
+
get '/jobs/next' do
|
36
|
+
next_job, runner = Job.next(params, @env['REMOTE_ADDR'])
|
37
|
+
if next_job
|
38
|
+
next_job.update(:taken_at => Time.now, :taken_by_id => runner.id)
|
39
|
+
[ next_job[:id], next_job[:requester_mac], next_job[:project], next_job[:root], next_job[:type], (next_job[:jruby] == 1 ? 'jruby' : 'ruby'), next_job[:files] ].join(',')
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
put '/jobs/:id' do
|
44
|
+
Job.find(:id => params[:id].to_i).update(:result => params[:result]); nil
|
45
|
+
end
|
46
|
+
|
47
|
+
get '/runners/ping' do
|
48
|
+
return unless Server.valid_version?(params[:version])
|
49
|
+
runner = Runner.find(:uid => params[:uid])
|
50
|
+
runner.update(params.merge({ :last_seen_at => Time.now })) if runner
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
|
54
|
+
get '/runners/outdated' do
|
55
|
+
Runner.find_all_outdated.map { |runner| [ runner[:ip], runner[:hostname], runner[:uid] ].join(' ') }.join("\n").strip
|
56
|
+
end
|
57
|
+
|
58
|
+
get '/runners/available_instances' do
|
59
|
+
Runner.available_instances.to_s
|
60
|
+
end
|
61
|
+
|
62
|
+
get '/runners/total_instances' do
|
63
|
+
Runner.total_instances.to_s
|
64
|
+
end
|
65
|
+
|
66
|
+
get '/runners/available' do
|
67
|
+
Runner.find_all_available.map { |runner| [ runner[:ip], runner[:hostname], runner[:uid], runner[:username], runner[:idle_instances] ].join(' ') }.join("\n").strip
|
68
|
+
end
|
69
|
+
|
70
|
+
get '/version' do
|
71
|
+
Testbot::VERSION
|
72
|
+
end
|