sauce 0.7.2 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- data/VERSION +1 -1
- data/bin/sauce +56 -49
- data/lib/sauce.rb +1 -0
- data/lib/sauce/config.rb +6 -0
- data/lib/sauce/connect.rb +58 -0
- data/lib/sauce/integrations.rb +14 -0
- data/spec/other_spec.rb +7 -0
- data/{test → spec}/saucelabs_spec.rb +1 -9
- data/support/sauce_connect +826 -0
- data/support/simplejson/LICENSE.txt +19 -0
- data/support/simplejson/__init__.py +437 -0
- data/support/simplejson/decoder.py +421 -0
- data/support/simplejson/encoder.py +501 -0
- data/support/simplejson/ordered_dict.py +119 -0
- data/support/simplejson/scanner.py +77 -0
- data/support/simplejson/tool.py +39 -0
- data/test/helper.rb +8 -2
- data/test/test_connect.rb +25 -0
- metadata +20 -7
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.8.0
|
data/bin/sauce
CHANGED
@@ -6,61 +6,68 @@ require 'yaml'
|
|
6
6
|
|
7
7
|
sauce_dir = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
|
8
8
|
$LOAD_PATH.unshift(sauce_dir) unless $LOAD_PATH.include?(sauce_dir)
|
9
|
-
require 'sauce'
|
10
9
|
|
11
|
-
|
12
|
-
|
13
|
-
|
10
|
+
# special case for sauce connect
|
11
|
+
if ARGV.length > 0 && ARGV[0] == 'connect'
|
12
|
+
require 'sauce/connect'
|
13
|
+
system ([Sauce::Connect.find_sauce_connect] + ARGV[1..100]).join(" ")
|
14
|
+
else
|
15
|
+
require 'sauce'
|
14
16
|
|
15
|
-
cmd
|
17
|
+
cmd = CmdParse::CommandParser.new(true, true)
|
18
|
+
cmd.program_name = "sauce "
|
19
|
+
cmd.program_version = [0, 1, 0]
|
16
20
|
|
17
|
-
|
18
|
-
|
19
|
-
configure
|
20
|
-
configure.
|
21
|
-
|
22
|
-
|
23
|
-
|
21
|
+
cmd.add_command(CmdParse::HelpCommand.new)
|
22
|
+
|
23
|
+
# configure
|
24
|
+
configure = CmdParse::Command.new('configure', false)
|
25
|
+
configure.short_desc = "Configure Sauce OnDemand credentials"
|
26
|
+
configure.set_execution_block do |args|
|
27
|
+
if args.length < 2:
|
28
|
+
puts "Usage: sauce configure USERNAME ACCESS_KEY"
|
29
|
+
exit 1
|
30
|
+
end
|
31
|
+
username = args[0]
|
32
|
+
access_key = args[1]
|
33
|
+
out = File.new(File.join(File.dirname(File.expand_path(File.dirname(__FILE__))), "ondemand.yml"), 'w')
|
34
|
+
out.write(YAML.dump({"username" => username, "access_key" => access_key}))
|
35
|
+
out.close()
|
24
36
|
end
|
25
|
-
|
26
|
-
access_key = args[1]
|
27
|
-
out = File.new(File.join(File.dirname(File.expand_path(File.dirname(__FILE__))), "ondemand.yml"), 'w')
|
28
|
-
out.write(YAML.dump({"username" => username, "access_key" => access_key}))
|
29
|
-
out.close()
|
30
|
-
end
|
31
|
-
cmd.add_command(configure)
|
37
|
+
cmd.add_command(configure)
|
32
38
|
|
33
|
-
#create
|
34
|
-
create = CmdParse::Command.new('create', false)
|
35
|
-
create.short_desc = "Create a new Sauce OnDemand account"
|
36
|
-
create.set_execution_block do |args|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
39
|
+
#create
|
40
|
+
create = CmdParse::Command.new('create', false)
|
41
|
+
create.short_desc = "Create a new Sauce OnDemand account"
|
42
|
+
create.set_execution_block do |args|
|
43
|
+
puts "Let's create a new account!"
|
44
|
+
print "Username: "
|
45
|
+
username = $stdin.gets.chomp
|
46
|
+
print "password: "
|
47
|
+
password = $stdin.gets.chomp
|
48
|
+
print "password confirmation: "
|
49
|
+
password_confirmation = $stdin.gets.chomp
|
50
|
+
print "email: "
|
51
|
+
email = $stdin.gets.chomp
|
52
|
+
print "Full name: "
|
53
|
+
name = $stdin.gets.chomp
|
48
54
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
55
|
+
# TODO: Add error handling, of course
|
56
|
+
result = RestClient.post "http://saucelabs.com/rest/v1/users",
|
57
|
+
{
|
58
|
+
:username => username,
|
59
|
+
:password => password,
|
60
|
+
:password_confirmation => password_confirmation,
|
61
|
+
:email => email,
|
62
|
+
:token => "c8eb3e2645005bcbbce7e2c208c6b7a71555d908",
|
63
|
+
:name => name
|
64
|
+
}.to_json,
|
65
|
+
:content_type => :json, :accept => :json
|
60
66
|
|
61
|
-
|
62
|
-
end
|
67
|
+
puts result.inspect
|
68
|
+
end
|
63
69
|
|
64
|
-
cmd.add_command(create)
|
70
|
+
cmd.add_command(create)
|
65
71
|
|
66
|
-
cmd.parse
|
72
|
+
cmd.parse
|
73
|
+
end
|
data/lib/sauce.rb
CHANGED
data/lib/sauce/config.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'json'
|
2
2
|
require 'yaml'
|
3
|
+
require 'uri'
|
3
4
|
|
4
5
|
module Sauce
|
5
6
|
def self.config
|
@@ -59,6 +60,11 @@ module Sauce
|
|
59
60
|
return [[os, browser, browser_version]]
|
60
61
|
end
|
61
62
|
|
63
|
+
def domain
|
64
|
+
return @opts[:domain] if @opts.include? :domain
|
65
|
+
return URI.parse(@opts[:browser_url]).host
|
66
|
+
end
|
67
|
+
|
62
68
|
private
|
63
69
|
|
64
70
|
def load_options_from_environment
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module Sauce
|
2
|
+
class Connect
|
3
|
+
attr_reader :status, :error
|
4
|
+
|
5
|
+
def initialize(options={})
|
6
|
+
@ready = false
|
7
|
+
@status = "uninitialized"
|
8
|
+
@error = nil
|
9
|
+
host = options[:host] || '127.0.0.1'
|
10
|
+
port = options[:port] || '3000'
|
11
|
+
options.delete(:host)
|
12
|
+
options.delete(:port)
|
13
|
+
config = Sauce::Config.new(options)
|
14
|
+
args = ['-u', config.username, '-k', config.access_key, '-s', host, '-p', port, '-d', config.domain]
|
15
|
+
@pipe = IO.popen(([Sauce::Connect.find_sauce_connect] + args).join(' '))
|
16
|
+
at_exit do
|
17
|
+
Process.kill("INT", @pipe.pid)
|
18
|
+
while @ready
|
19
|
+
sleep 1
|
20
|
+
end
|
21
|
+
end
|
22
|
+
Thread.new {
|
23
|
+
while( (line = @pipe.gets) )
|
24
|
+
if line =~ /Tunnel host is (.*) (\.\.|at)/
|
25
|
+
@status = $1
|
26
|
+
end
|
27
|
+
if line =~/You may start your tests/
|
28
|
+
@ready = true
|
29
|
+
end
|
30
|
+
if line =~ /- (Problem.*)$/
|
31
|
+
@error = $1
|
32
|
+
end
|
33
|
+
puts line
|
34
|
+
end
|
35
|
+
@ready = false
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
def wait_until_ready
|
40
|
+
while(!@ready)
|
41
|
+
sleep 0.4
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def disconnect
|
46
|
+
if @ready
|
47
|
+
Process.kill("INT", @pipe.pid)
|
48
|
+
while @ready
|
49
|
+
sleep 1
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.find_sauce_connect
|
55
|
+
File.join(File.dirname(File.dirname(File.expand_path(File.dirname(__FILE__)))), "support", "sauce_connect")
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/lib/sauce/integrations.rb
CHANGED
@@ -5,6 +5,20 @@ begin
|
|
5
5
|
class SeleniumExampleGroup < Spec::Example::ExampleGroup
|
6
6
|
attr_reader :selenium
|
7
7
|
|
8
|
+
before :suite do
|
9
|
+
config = Sauce::Config.new
|
10
|
+
if config.application_host
|
11
|
+
@@tunnel = Sauce::Connect.new(:host => config.application_host, :port => config.application_port || 80)
|
12
|
+
@@tunnel.wait_until_ready
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
after :suite do
|
17
|
+
if defined? @@tunnel
|
18
|
+
@@tunnel.disconnect
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
8
22
|
before(:each) do
|
9
23
|
@selenium.start
|
10
24
|
end
|
data/spec/other_spec.rb
ADDED
@@ -1,12 +1,4 @@
|
|
1
|
-
require
|
2
|
-
|
3
|
-
# This should go in a test helper
|
4
|
-
Sauce.config do |config|
|
5
|
-
config.browsers = [
|
6
|
-
["Windows 2003", "firefox", "3.6."],
|
7
|
-
["Windows 2003", "safariproxy", "5."]
|
8
|
-
]
|
9
|
-
end
|
1
|
+
require File.join(File.dirname(File.expand_path(File.dirname(__FILE__))), "test", "helper")
|
10
2
|
|
11
3
|
describe "The Sauce website", :type => :selenium do
|
12
4
|
it "works" do
|
@@ -0,0 +1,826 @@
|
|
1
|
+
#!/usr/bin/env python
|
2
|
+
# encoding: utf-8
|
3
|
+
from __future__ import with_statement
|
4
|
+
|
5
|
+
# TODO:
|
6
|
+
# * Move to REST API v1
|
7
|
+
# * windows: SSH link healthcheck (PuTTY session file hack?)
|
8
|
+
# * Daemonizing
|
9
|
+
# * issue: windows: no os.fork()
|
10
|
+
# * issue: unix: null file descriptors causes Expect script to fail
|
11
|
+
# * Renew tunnel lease (backend not implemented)
|
12
|
+
#
|
13
|
+
|
14
|
+
import os
|
15
|
+
import sys
|
16
|
+
import re
|
17
|
+
import optparse
|
18
|
+
import logging
|
19
|
+
import logging.handlers
|
20
|
+
import signal
|
21
|
+
import httplib
|
22
|
+
import urllib2
|
23
|
+
import subprocess
|
24
|
+
import socket
|
25
|
+
import time
|
26
|
+
import platform
|
27
|
+
import tempfile
|
28
|
+
import string
|
29
|
+
from collections import defaultdict
|
30
|
+
from contextlib import closing
|
31
|
+
from functools import wraps
|
32
|
+
|
33
|
+
try:
|
34
|
+
import json
|
35
|
+
except ImportError:
|
36
|
+
import simplejson as json # Python 2.5 dependency
|
37
|
+
|
38
|
+
NAME = "sauce_connect"
|
39
|
+
RELEASE = 17
|
40
|
+
DISPLAY_VERSION = "%s release %s" % (NAME, RELEASE)
|
41
|
+
PRODUCT_NAME = u"Sauce Connect"
|
42
|
+
VERSIONS_URL = "http://saucelabs.com/versions.json"
|
43
|
+
|
44
|
+
RETRY_PROVISION_MAX = 4
|
45
|
+
RETRY_BOOT_MAX = 4
|
46
|
+
RETRY_REST_WAIT = 5
|
47
|
+
RETRY_REST_MAX = 6
|
48
|
+
REST_POLL_WAIT = 3
|
49
|
+
RETRY_SSH_MAX = 4
|
50
|
+
HEALTH_CHECK_INTERVAL = 15
|
51
|
+
HEALTH_CHECK_FAIL = 5 * 60 # no good check after this amount of time == fail
|
52
|
+
SIGNALS_RECV_MAX = 4 # used with --allow-unclean-exit
|
53
|
+
|
54
|
+
is_windows = platform.system().lower() == "windows"
|
55
|
+
is_openbsd = platform.system().lower() == "openbsd"
|
56
|
+
logger = logging.getLogger(NAME)
|
57
|
+
|
58
|
+
|
59
|
+
class HTTPResponseError(Exception):
|
60
|
+
|
61
|
+
def __init__(self, msg):
|
62
|
+
self.msg = msg
|
63
|
+
|
64
|
+
def __str__(self):
|
65
|
+
return "HTTP server responded with '%s' (expected 'OK')" % self.msg
|
66
|
+
|
67
|
+
|
68
|
+
class TunnelMachineError(Exception):
|
69
|
+
pass
|
70
|
+
|
71
|
+
|
72
|
+
class TunnelMachineProvisionError(TunnelMachineError):
|
73
|
+
pass
|
74
|
+
|
75
|
+
|
76
|
+
class TunnelMachineBootError(TunnelMachineError):
|
77
|
+
pass
|
78
|
+
|
79
|
+
|
80
|
+
class TunnelMachine(object):
|
81
|
+
|
82
|
+
_host_search = re.compile("//([^/]+)").search
|
83
|
+
|
84
|
+
def __init__(self, rest_url, user, password, domains, metadata=None):
|
85
|
+
self.user = user
|
86
|
+
self.password = password
|
87
|
+
self.domains = set(domains)
|
88
|
+
self.metadata = metadata or dict()
|
89
|
+
|
90
|
+
self.reverse_ssh = None
|
91
|
+
self.is_shutdown = False
|
92
|
+
self.base_url = "%(rest_url)s/%(user)s/tunnels" % locals()
|
93
|
+
self.rest_host = self._host_search(rest_url).group(1)
|
94
|
+
self.basic_auth_header = {"Authorization": "Basic %s" %
|
95
|
+
("%s:%s" % (user, password)).encode("base64").strip()}
|
96
|
+
|
97
|
+
self._set_urlopen(user, password)
|
98
|
+
|
99
|
+
for attempt in xrange(1, RETRY_PROVISION_MAX):
|
100
|
+
try:
|
101
|
+
self._provision_tunnel()
|
102
|
+
break
|
103
|
+
except TunnelMachineProvisionError, e:
|
104
|
+
logger.warning(e)
|
105
|
+
if attempt == RETRY_PROVISION_MAX:
|
106
|
+
raise TunnelMachineError(
|
107
|
+
"!! Could not provision tunnel host. Please contact "
|
108
|
+
"help@saucelabs.com.")
|
109
|
+
|
110
|
+
def _set_urlopen(self, user, password):
|
111
|
+
# always send Basic Auth header for GET and POST
|
112
|
+
# NOTE: we directly construct the header because it is more reliable
|
113
|
+
# and more efficient than HTTPBasicAuthHandler and we always need it
|
114
|
+
opener = urllib2.build_opener()
|
115
|
+
opener.addheaders = self.basic_auth_header.items()
|
116
|
+
self.urlopen = opener.open
|
117
|
+
|
118
|
+
# decorator
|
119
|
+
def _retry_rest_api(f):
|
120
|
+
@wraps(f)
|
121
|
+
def wrapper(*args, **kwargs):
|
122
|
+
previous_failed = False
|
123
|
+
for attempt in xrange(1, RETRY_REST_MAX + 1):
|
124
|
+
try:
|
125
|
+
result = f(*args, **kwargs)
|
126
|
+
if previous_failed:
|
127
|
+
logger.info(
|
128
|
+
"Connection succeeded")
|
129
|
+
return result
|
130
|
+
except (HTTPResponseError,
|
131
|
+
urllib2.URLError, httplib.HTTPException,
|
132
|
+
socket.gaierror, socket.error), e:
|
133
|
+
logger.warning("Problem connecting to Sauce Labs REST API "
|
134
|
+
"(%s)", str(e))
|
135
|
+
if attempt == RETRY_REST_MAX:
|
136
|
+
raise TunnelMachineError(
|
137
|
+
"Could not reach Sauce Labs REST API after %d "
|
138
|
+
"tries. Is your network down or firewalled?"
|
139
|
+
% attempt)
|
140
|
+
previous_failed = True
|
141
|
+
logger.debug("Retrying in %ds", RETRY_REST_WAIT)
|
142
|
+
time.sleep(RETRY_REST_WAIT)
|
143
|
+
return wrapper
|
144
|
+
|
145
|
+
@_retry_rest_api
|
146
|
+
def _get_doc(self, url_or_req):
|
147
|
+
with closing(self.urlopen(url_or_req)) as resp:
|
148
|
+
if resp.msg != "OK":
|
149
|
+
raise HTTPResponseError(resp.msg)
|
150
|
+
return json.loads(resp.read())
|
151
|
+
|
152
|
+
@_retry_rest_api
|
153
|
+
def _get_delete_doc(self, url):
|
154
|
+
# urllib2 doesn support the DELETE method (lame), so we build our own
|
155
|
+
if self.base_url.startswith("https"):
|
156
|
+
make_conn = httplib.HTTPSConnection
|
157
|
+
else:
|
158
|
+
make_conn = httplib.HTTPConnection
|
159
|
+
with closing(make_conn(self.rest_host)) as conn:
|
160
|
+
conn.request(method="DELETE", url=url,
|
161
|
+
headers=self.basic_auth_header)
|
162
|
+
resp = conn.getresponse()
|
163
|
+
if resp.reason != "OK":
|
164
|
+
raise HTTPResponseError(resp.reason)
|
165
|
+
return json.loads(resp.read())
|
166
|
+
|
167
|
+
def _provision_tunnel(self):
|
168
|
+
# Shutdown any tunnel using a requested domain
|
169
|
+
kill_list = set()
|
170
|
+
for doc in self._get_doc(self.base_url):
|
171
|
+
if not doc.get('DomainNames'):
|
172
|
+
continue
|
173
|
+
if set(doc['DomainNames']) & self.domains:
|
174
|
+
kill_list.add(doc['id'])
|
175
|
+
if kill_list:
|
176
|
+
logger.info(
|
177
|
+
"Shutting down other tunnel hosts using requested domains")
|
178
|
+
for tunnel_id in kill_list:
|
179
|
+
for attempt in xrange(1, 4): # try a few times, then bail
|
180
|
+
logger.debug(
|
181
|
+
"Shutting down old tunnel host: %s" % tunnel_id)
|
182
|
+
url = "%s/%s" % (self.base_url, tunnel_id)
|
183
|
+
doc = self._get_delete_doc(url)
|
184
|
+
if not doc.get('ok'):
|
185
|
+
logger.warning("Old tunnel host failed to shutdown?")
|
186
|
+
continue
|
187
|
+
doc = self._get_doc(url)
|
188
|
+
while doc.get('Status') not in ["halting", "terminated"]:
|
189
|
+
logger.debug(
|
190
|
+
"Waiting for old tunnel host to start halting")
|
191
|
+
time.sleep(REST_POLL_WAIT)
|
192
|
+
doc = self._get_doc(url)
|
193
|
+
break
|
194
|
+
|
195
|
+
# Request a tunnel machine
|
196
|
+
headers = {"Content-Type": "application/json"}
|
197
|
+
data = json.dumps(dict(DomainNames=list(self.domains),
|
198
|
+
Metadata=self.metadata))
|
199
|
+
req = urllib2.Request(url=self.base_url, headers=headers, data=data)
|
200
|
+
doc = self._get_doc(req)
|
201
|
+
if doc.get('error'):
|
202
|
+
raise TunnelMachineProvisionError(doc['error'])
|
203
|
+
for key in ['ok', 'id']:
|
204
|
+
if not doc.get(key):
|
205
|
+
raise TunnelMachineProvisionError(
|
206
|
+
"Document for provisioned tunnel host is missing the key "
|
207
|
+
"or value for '%s'" % key)
|
208
|
+
self.id = doc['id']
|
209
|
+
self.url = "%s/%s" % (self.base_url, self.id)
|
210
|
+
logger.debug("Provisioned tunnel host: %s" % self.id)
|
211
|
+
|
212
|
+
def ready_wait(self):
|
213
|
+
"""Wait for the machine to reach the 'running' state."""
|
214
|
+
previous_status = None
|
215
|
+
while True:
|
216
|
+
doc = self._get_doc(self.url)
|
217
|
+
status = doc.get('Status')
|
218
|
+
if status == "running":
|
219
|
+
break
|
220
|
+
if status in ["halting", "terminated"]:
|
221
|
+
raise TunnelMachineBootError("Tunnel host was shutdown")
|
222
|
+
if status != previous_status:
|
223
|
+
logger.info("Tunnel host is %s .." % status)
|
224
|
+
previous_status = status
|
225
|
+
time.sleep(REST_POLL_WAIT)
|
226
|
+
self.host = doc['Host']
|
227
|
+
logger.info("Tunnel host is running at %s" % self.host)
|
228
|
+
|
229
|
+
def shutdown(self):
|
230
|
+
if self.is_shutdown:
|
231
|
+
return
|
232
|
+
|
233
|
+
if self.reverse_ssh:
|
234
|
+
self.reverse_ssh.stop()
|
235
|
+
|
236
|
+
logger.info("Shutting down tunnel host (please wait)")
|
237
|
+
logger.debug("Tunnel host ID: %s" % self.id)
|
238
|
+
|
239
|
+
doc = self._get_delete_doc(self.url)
|
240
|
+
assert doc.get('ok')
|
241
|
+
|
242
|
+
previous_status = None
|
243
|
+
while True:
|
244
|
+
doc = self._get_doc(self.url)
|
245
|
+
status = doc.get('Status')
|
246
|
+
if status == "terminated":
|
247
|
+
break
|
248
|
+
if status != previous_status:
|
249
|
+
logger.info("Tunnel host is %s .." % status)
|
250
|
+
previous_status = status
|
251
|
+
time.sleep(REST_POLL_WAIT)
|
252
|
+
logger.info("Tunnel host is shutdown")
|
253
|
+
self.is_shutdown = True
|
254
|
+
|
255
|
+
# Make us usable with contextlib.closing
|
256
|
+
close = shutdown
|
257
|
+
|
258
|
+
def check_running(self):
|
259
|
+
doc = self._get_doc(self.url)
|
260
|
+
if doc.get('Status') == "running":
|
261
|
+
return
|
262
|
+
raise TunnelMachineError(
|
263
|
+
"The tunnel host is no longer running. It may have been shutdown "
|
264
|
+
"via the website or by another Sauce Connect script requesting these "
|
265
|
+
"domains: %s" % list(self.domains))
|
266
|
+
|
267
|
+
|
268
|
+
class HealthCheckFail(Exception):
|
269
|
+
pass
|
270
|
+
|
271
|
+
|
272
|
+
class HealthChecker(object):
|
273
|
+
|
274
|
+
def __init__(self, host, ports, fail_msg=None):
|
275
|
+
"""fail_msg can include '%(host)s' and '%(port)d'"""
|
276
|
+
self.host = host
|
277
|
+
self.fail_msg = fail_msg
|
278
|
+
if not self.fail_msg:
|
279
|
+
self.fail_msg = ("!! Your tests will fail while your network "
|
280
|
+
"can not get to %(host)s:%(port)d.")
|
281
|
+
self.ports = frozenset(int(p) for p in ports)
|
282
|
+
self.last_tcp_connect = defaultdict(time.time)
|
283
|
+
self.previous_failed = defaultdict(lambda: False)
|
284
|
+
|
285
|
+
def _tcp_connected(self, port):
|
286
|
+
with closing(socket.socket()) as sock:
|
287
|
+
try:
|
288
|
+
sock.connect((self.host, port))
|
289
|
+
return True
|
290
|
+
except (socket.gaierror, socket.error), e:
|
291
|
+
logger.warning("Could not connect to %s:%s (%s)",
|
292
|
+
self.host, port, str(e))
|
293
|
+
return False
|
294
|
+
|
295
|
+
def check(self):
|
296
|
+
for port in self.ports:
|
297
|
+
if self._tcp_connected(port):
|
298
|
+
# TCP connection succeeded
|
299
|
+
self.last_tcp_connect[port] = time.time()
|
300
|
+
if self.previous_failed[port]:
|
301
|
+
logger.info(
|
302
|
+
"Succesfully connected to %s:%s" % (self.host, port))
|
303
|
+
self.previous_failed[port] = False
|
304
|
+
continue
|
305
|
+
# TCP connection failed
|
306
|
+
self.previous_failed[port] = True
|
307
|
+
logger.warning(self.fail_msg % dict(host=self.host, port=port))
|
308
|
+
if time.time() - self.last_tcp_connect[port] > HEALTH_CHECK_FAIL:
|
309
|
+
raise HealthCheckFail(
|
310
|
+
"Could not connect to %s:%s for %s seconds"
|
311
|
+
% (self.host, port, HEALTH_CHECK_FAIL))
|
312
|
+
|
313
|
+
|
314
|
+
class ReverseSSHError(Exception):
|
315
|
+
pass
|
316
|
+
|
317
|
+
|
318
|
+
class ReverseSSH(object):
|
319
|
+
|
320
|
+
def __init__(self, tunnel, host, ports, tunnel_ports, debug=False):
|
321
|
+
self.tunnel = tunnel
|
322
|
+
self.host = host
|
323
|
+
self.ports = ports
|
324
|
+
self.tunnel_ports = tunnel_ports
|
325
|
+
self.debug = debug
|
326
|
+
|
327
|
+
self.proc = None
|
328
|
+
self.readyfile = None
|
329
|
+
self.stdout_f = None
|
330
|
+
self.stderr_f = None
|
331
|
+
|
332
|
+
if self.debug:
|
333
|
+
logger.debug("ReverseSSH debugging is on.")
|
334
|
+
|
335
|
+
def _check_dot_ssh_files(self):
|
336
|
+
if not os.environ.get('HOME'):
|
337
|
+
logger.debug("No HOME env, skipping .ssh file checks")
|
338
|
+
return
|
339
|
+
|
340
|
+
ssh_config_file = os.path.join(os.environ['HOME'], ".ssh", "config")
|
341
|
+
if os.path.exists(ssh_config_file):
|
342
|
+
logger.debug("Found %s" % ssh_config_file)
|
343
|
+
|
344
|
+
ssh_known_hosts = os.path.join(os.environ['HOME'], ".ssh", "known_hosts")
|
345
|
+
if os.path.exists(ssh_known_hosts):
|
346
|
+
if not os.path.isfile(ssh_known_hosts) or os.path.islink(ssh_known_hosts):
|
347
|
+
logger.debug("SSH known_hosts file (%s) is not a regular file "
|
348
|
+
% ssh_known_hosts)
|
349
|
+
|
350
|
+
@property
|
351
|
+
def _dash_Rs(self):
|
352
|
+
dash_Rs = ""
|
353
|
+
for port, tunnel_port in zip(self.ports, self.tunnel_ports):
|
354
|
+
dash_Rs += "-R 0.0.0.0:%s:%s:%s " % (tunnel_port, self.host, port)
|
355
|
+
return dash_Rs
|
356
|
+
|
357
|
+
def get_plink_command(self):
|
358
|
+
verbosity = "-v" if self.debug else ""
|
359
|
+
return ("plink\plink %s -l %s -pw %s -N %s %s"
|
360
|
+
% (verbosity, self.tunnel.user, self.tunnel.password,
|
361
|
+
self._dash_Rs, self.tunnel.host))
|
362
|
+
|
363
|
+
def get_expect_script(self):
|
364
|
+
wait = "wait"
|
365
|
+
if is_openbsd: # using 'wait;' hangs the script on OpenBSD
|
366
|
+
wait = "wait -nowait;sleep 1" # hack
|
367
|
+
|
368
|
+
verbosity = "-v" if self.debug else "-q"
|
369
|
+
host_ip = socket.gethostbyname(self.tunnel.host)
|
370
|
+
script = (
|
371
|
+
"spawn ssh-keygen %s -R %s;%s;"
|
372
|
+
% (verbosity, self.tunnel.host, wait) +
|
373
|
+
"spawn ssh-keygen %s -R %s;%s;" % (verbosity, host_ip, wait) +
|
374
|
+
"spawn ssh %s -p 22 -l %s -o ServerAliveInterval=%s -N %s %s;"
|
375
|
+
% (verbosity, self.tunnel.user, HEALTH_CHECK_INTERVAL,
|
376
|
+
self._dash_Rs, self.tunnel.host) +
|
377
|
+
'expect \\"Are you sure you want to continue connecting'
|
378
|
+
' (yes/no)?\\";send yes\\r;'
|
379
|
+
"expect *password:;send -- %s\\r;" % self.tunnel.password +
|
380
|
+
"expect -timeout -1 timeout")
|
381
|
+
return script
|
382
|
+
|
383
|
+
def _start_reverse_ssh(self, readyfile=None):
|
384
|
+
self._check_dot_ssh_files()
|
385
|
+
logger.info("Starting SSH process ..")
|
386
|
+
if is_windows:
|
387
|
+
cmd = "echo 'n' | %s" % self.get_plink_command()
|
388
|
+
else:
|
389
|
+
cmd = 'exec expect -c "%s"' % self.get_expect_script()
|
390
|
+
|
391
|
+
# start ssh process
|
392
|
+
if self.debug:
|
393
|
+
self.stdout_f = tempfile.TemporaryFile()
|
394
|
+
else:
|
395
|
+
self.stdout_f = open(os.devnull)
|
396
|
+
self.stderr_f = tempfile.TemporaryFile()
|
397
|
+
self.proc = subprocess.Popen(
|
398
|
+
cmd, shell=True, stdout=self.stdout_f, stderr=self.stderr_f)
|
399
|
+
self.tunnel.reverse_ssh = self # BUG: circular ref
|
400
|
+
time.sleep(3) # HACK: some startup time
|
401
|
+
|
402
|
+
# ssh process is running
|
403
|
+
announced_running = False
|
404
|
+
forwarded_health = HealthChecker(self.host, self.ports)
|
405
|
+
tunnel_health = HealthChecker(host=self.tunnel.host, ports=[22],
|
406
|
+
fail_msg="!! Your tests may fail because your network can not get "
|
407
|
+
"to the tunnel host (%s:%d)." % (self.tunnel.host, 22))
|
408
|
+
start_time = int(time.time())
|
409
|
+
while self.proc.poll() is None:
|
410
|
+
now = int(time.time())
|
411
|
+
if not announced_running:
|
412
|
+
# guarantee we health check on first iteration
|
413
|
+
now = start_time
|
414
|
+
if (now - start_time) % HEALTH_CHECK_INTERVAL == 0:
|
415
|
+
self.tunnel.check_running()
|
416
|
+
try:
|
417
|
+
forwarded_health.check()
|
418
|
+
tunnel_health.check()
|
419
|
+
except HealthCheckFail, e:
|
420
|
+
raise ReverseSSHError(e)
|
421
|
+
if not announced_running:
|
422
|
+
logger.info("SSH is running. You may start your tests.")
|
423
|
+
if readyfile:
|
424
|
+
self.readyfile = readyfile
|
425
|
+
f = open(readyfile, 'w')
|
426
|
+
f.close()
|
427
|
+
announced_running = True
|
428
|
+
time.sleep(1)
|
429
|
+
|
430
|
+
# ssh process has exited
|
431
|
+
self._log_output()
|
432
|
+
if self.proc.returncode != 0:
|
433
|
+
logger.warning("SSH process exited with error code %d",
|
434
|
+
self.proc.returncode)
|
435
|
+
else:
|
436
|
+
logger.info("SSH process exited (maybe due to network problems)")
|
437
|
+
|
438
|
+
return self.proc.returncode
|
439
|
+
|
440
|
+
def _log_output(self):
|
441
|
+
if not self.stderr_f.closed:
|
442
|
+
self.stderr_f.seek(0)
|
443
|
+
reverse_ssh_stderr = self.stderr_f.read().strip()
|
444
|
+
self.stderr_f.close()
|
445
|
+
|
446
|
+
if reverse_ssh_stderr:
|
447
|
+
logger.debug("ReverseSSH stderr was:\n%s\n" % reverse_ssh_stderr)
|
448
|
+
|
449
|
+
if not self.stdout_f.closed:
|
450
|
+
self.stdout_f.seek(0)
|
451
|
+
reverse_ssh_stdout = self.stdout_f.read().strip()
|
452
|
+
self.stdout_f.close()
|
453
|
+
|
454
|
+
if self.debug:
|
455
|
+
logger.debug("ReverseSSH stdout was:\n%s\n" % reverse_ssh_stdout)
|
456
|
+
|
457
|
+
def _rm_readyfile(self):
|
458
|
+
if self.readyfile and os.path.exists(self.readyfile):
|
459
|
+
try:
|
460
|
+
os.remove(self.readyfile)
|
461
|
+
except OSError, e:
|
462
|
+
logger.error("Couldn't remove %s: %s", self.readyfile, str(e))
|
463
|
+
|
464
|
+
def stop(self):
|
465
|
+
self._rm_readyfile()
|
466
|
+
self._log_output()
|
467
|
+
if is_windows or not self.proc:
|
468
|
+
return
|
469
|
+
try:
|
470
|
+
os.kill(self.proc.pid, signal.SIGHUP)
|
471
|
+
logger.debug("Sent SIGHUP to PID %d", self.proc.pid)
|
472
|
+
except OSError:
|
473
|
+
pass
|
474
|
+
|
475
|
+
def run(self, readyfile=None):
|
476
|
+
clean_exit = False
|
477
|
+
for attempt in xrange(1, RETRY_SSH_MAX + 1):
|
478
|
+
# returncode 0 will happen due to ServerAlive checks failing.
|
479
|
+
# this may result in a listening port forwarding nowhere, so
|
480
|
+
# don't bother restarting the SSH connection.
|
481
|
+
# TODO: revisit if server uses OpenSSH instead of Twisted SSH
|
482
|
+
if self._start_reverse_ssh(readyfile) == 0:
|
483
|
+
clean_exit = True
|
484
|
+
self._rm_readyfile()
|
485
|
+
if not clean_exit:
|
486
|
+
raise ReverseSSHError(
|
487
|
+
"SSH process errored %d times (bad network?)" % attempt)
|
488
|
+
|
489
|
+
|
490
|
+
def peace_out(tunnel=None, returncode=0):
|
491
|
+
"""Shutdown the tunnel and raise SystemExit."""
|
492
|
+
if tunnel:
|
493
|
+
tunnel.shutdown()
|
494
|
+
logger.info("\ Exiting /")
|
495
|
+
raise SystemExit(returncode)
|
496
|
+
|
497
|
+
|
498
|
+
def setup_signal_handler(tunnel, options):
|
499
|
+
signal_count = defaultdict(int)
|
500
|
+
|
501
|
+
def sig_handler(signum, frame):
|
502
|
+
if options.allow_unclean_exit:
|
503
|
+
signal_count[signum] += 1
|
504
|
+
if signal_count[signum] > SIGNALS_RECV_MAX:
|
505
|
+
logger.info("Received signal %d too many times (%d). Making "
|
506
|
+
"unclean exit now!", signum, signal_count[signum])
|
507
|
+
raise SystemExit(1)
|
508
|
+
logger.info("Received signal %d", signum)
|
509
|
+
peace_out(tunnel) # exits
|
510
|
+
|
511
|
+
# TODO: ?? remove SIGTERM when we implement tunnel leases
|
512
|
+
if is_windows:
|
513
|
+
# TODO: What do these Windows signals really mean?
|
514
|
+
supported_signals = ["SIGABRT", "SIGBREAK", "SIGINT", "SIGTERM"]
|
515
|
+
else:
|
516
|
+
supported_signals = ["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM"]
|
517
|
+
for sig in supported_signals:
|
518
|
+
signal.signal(getattr(signal, sig), sig_handler)
|
519
|
+
|
520
|
+
|
521
|
+
def check_version():
|
522
|
+
failed_msg = "Skipping version check"
|
523
|
+
logger.debug("Checking version")
|
524
|
+
try:
|
525
|
+
with closing(urllib2.urlopen(VERSIONS_URL)) as resp:
|
526
|
+
assert resp.msg == "OK", "Got HTTP response %s" % resp.msg
|
527
|
+
version_doc = json.loads(resp.read())
|
528
|
+
except (urllib2.URLError, AssertionError, ValueError), e:
|
529
|
+
logger.debug("Could not check version: %s", str(e))
|
530
|
+
logger.info(failed_msg)
|
531
|
+
return
|
532
|
+
try:
|
533
|
+
version = version_doc[PRODUCT_NAME][u'version']
|
534
|
+
download_url = version_doc[PRODUCT_NAME][u'download_url']
|
535
|
+
except KeyError, e:
|
536
|
+
logger.debug("Bad version doc, missing key: %s", str(e))
|
537
|
+
logger.info(failed_msg)
|
538
|
+
return
|
539
|
+
|
540
|
+
try:
|
541
|
+
latest = int(version.partition("-")[2].strip(string.ascii_letters))
|
542
|
+
except (IndexError, ValueError), e:
|
543
|
+
logger.debug("Couldn't parse release number: %s", str(e))
|
544
|
+
logger.info(failed_msg)
|
545
|
+
return
|
546
|
+
if RELEASE < latest:
|
547
|
+
update_msg = "** Please update %s: %s" % (PRODUCT_NAME, download_url)
|
548
|
+
logger.warning(update_msg)
|
549
|
+
sys.stderr.write("%s\n" % update_msg)
|
550
|
+
|
551
|
+
|
552
|
+
def setup_logging(logfile=None, quiet=False):
|
553
|
+
logger.setLevel(logging.DEBUG)
|
554
|
+
|
555
|
+
if not quiet:
|
556
|
+
stdout = logging.StreamHandler(sys.stdout)
|
557
|
+
stdout.setLevel(logging.INFO)
|
558
|
+
stdout.setFormatter(logging.Formatter("%(asctime)s - %(message)s"))
|
559
|
+
logger.addHandler(stdout)
|
560
|
+
|
561
|
+
if logfile:
|
562
|
+
if not quiet:
|
563
|
+
print "* Debug messages will be sent to %s" % logfile
|
564
|
+
fileout = logging.handlers.RotatingFileHandler(
|
565
|
+
filename=logfile, maxBytes=128 * 1024, backupCount=8)
|
566
|
+
fileout.setLevel(logging.DEBUG)
|
567
|
+
fileout.setFormatter(logging.Formatter(
|
568
|
+
"%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s"))
|
569
|
+
logger.addHandler(fileout)
|
570
|
+
|
571
|
+
|
572
|
+
def get_options():
|
573
|
+
usage = """
|
574
|
+
Usage: %(name)s -u <user> -k <api_key> -s <webserver> -d <domain> [options]
|
575
|
+
|
576
|
+
Examples:
|
577
|
+
Have tests for example.com go to a staging server on your intranet:
|
578
|
+
%(name)s -u user -k 123-abc -s staging.local -d example.com
|
579
|
+
|
580
|
+
Have HTTP and HTTPS traffic for *.example.com go to the staging server:
|
581
|
+
%(name)s -u user -k 123-abc -s staging.local -p 80 -p 443 \\
|
582
|
+
-d example.com -d *.example.com
|
583
|
+
|
584
|
+
Have tests for example.com go to your local machine on port 5000:
|
585
|
+
%(name)s -u user -k 123-abc -s 127.0.0.1 -t 80 -p 5000 -d example.com
|
586
|
+
|
587
|
+
Performance tip:
|
588
|
+
It is highly recommended you run this script on the same machine as your
|
589
|
+
test server (i.e., you would use "-s 127.0.0.1" or "-s localhost"). Using
|
590
|
+
a remote server introduces higher latency (slower web requests) and is
|
591
|
+
another failure point.
|
592
|
+
""" % dict(name=NAME)
|
593
|
+
|
594
|
+
usage = usage.strip()
|
595
|
+
logfile = "%s.log" % NAME
|
596
|
+
|
597
|
+
op = optparse.OptionParser(usage=usage, version=DISPLAY_VERSION)
|
598
|
+
op.add_option("-u", "--user", "--username",
|
599
|
+
help="Your Sauce Labs account name.")
|
600
|
+
op.add_option("-k", "--api-key",
|
601
|
+
help="On your account at https://saucelabs.com/account")
|
602
|
+
op.add_option("-s", "--host", default="localhost",
|
603
|
+
help="Host to forward requests to. [%default]")
|
604
|
+
op.add_option("-p", "--port", metavar="PORT",
|
605
|
+
action="append", dest="ports", default=[],
|
606
|
+
help="Forward to this port on HOST. Can be specified "
|
607
|
+
"multiple times. [80]")
|
608
|
+
op.add_option("-d", "--domain", action="append", dest="domains",
|
609
|
+
help="Repeat for each domain you want to forward requests for. "
|
610
|
+
"Example: -d example.test -d '*.example.test'")
|
611
|
+
op.add_option("-q", "--quiet", action="store_true", default=False,
|
612
|
+
help="Minimize standard output (see %s)" % logfile)
|
613
|
+
|
614
|
+
og = optparse.OptionGroup(op, "Advanced options")
|
615
|
+
og.add_option("-t", "--tunnel-port", metavar="TUNNEL_PORT",
|
616
|
+
action="append", dest="tunnel_ports", default=[],
|
617
|
+
help="The port your tests expect to hit when they run."
|
618
|
+
" By default, we use the same ports as the HOST."
|
619
|
+
" If you know for sure _all_ your tests use something like"
|
620
|
+
" http://site.test:8080/ then set this 8080.")
|
621
|
+
og.add_option("--logfile", default=logfile,
|
622
|
+
help="Path of the logfile to write to. [%default]")
|
623
|
+
og.add_option("--readyfile",
|
624
|
+
help="Path of the file to drop when the tunnel is ready "
|
625
|
+
"for tests to run. By default, no file is dropped.")
|
626
|
+
op.add_option_group(og)
|
627
|
+
|
628
|
+
og = optparse.OptionGroup(op, "Script debugging options")
|
629
|
+
og.add_option("--rest-url", default="https://saucelabs.com/rest",
|
630
|
+
help="[%default]")
|
631
|
+
og.add_option("--debug-ssh", action="store_true", default=False)
|
632
|
+
og.add_option("--allow-unclean-exit", action="store_true", default=False)
|
633
|
+
op.add_option_group(og)
|
634
|
+
|
635
|
+
(options, args) = op.parse_args()
|
636
|
+
|
637
|
+
# default to 80 and default to matching host ports with tunnel ports
|
638
|
+
if not options.ports and not options.tunnel_ports:
|
639
|
+
options.ports = ["80"]
|
640
|
+
if options.ports and not options.tunnel_ports:
|
641
|
+
options.tunnel_ports = options.ports[:]
|
642
|
+
|
643
|
+
if len(options.ports) != len(options.tunnel_ports):
|
644
|
+
sys.stderr.write("Error: Options -t and -p need to be paired\n\n")
|
645
|
+
print "Help with options -t and -p:"
|
646
|
+
print " When forwarding multiple ports, you must pair the tunnel port"
|
647
|
+
print " to forward with the host port to forward to."
|
648
|
+
print ""
|
649
|
+
print "Example option usage:"
|
650
|
+
print " To have your test's requests to 80 and 443 go to your test"
|
651
|
+
print " server on ports 5000 and 5001: -t 80 -p 5000 -t 443 -p 5001"
|
652
|
+
raise SystemExit(1)
|
653
|
+
|
654
|
+
# check for required options without defaults
|
655
|
+
for opt in ["user", "api_key", "host", "domains"]:
|
656
|
+
if not hasattr(options, opt) or not getattr(options, opt):
|
657
|
+
sys.stderr.write("Error: Missing required argument(s)\n\n")
|
658
|
+
op.print_help()
|
659
|
+
raise SystemExit(1)
|
660
|
+
|
661
|
+
# check for '/' in any domain names (might be a URL)
|
662
|
+
# TODO: domain is not an IP
|
663
|
+
# TODO: check domain uses a dot and a tld of 2 chars or more
|
664
|
+
if [dom for dom in options.domains if '/' in dom]:
|
665
|
+
sys.stderr.write(
|
666
|
+
"Error: Domain contains illegal character '/' in it.\n")
|
667
|
+
print " Did you use a URL instead of just the domain?\n"
|
668
|
+
print "Examples: -d example.com -d '*.example.com' -d cdn.example.org"
|
669
|
+
print ""
|
670
|
+
raise SystemExit(1)
|
671
|
+
|
672
|
+
return options
|
673
|
+
|
674
|
+
|
675
|
+
class MissingDependenciesError(Exception):
|
676
|
+
|
677
|
+
deb_pkg = dict(ssh="openssh-client", expect="expect")
|
678
|
+
|
679
|
+
def __init__(self, dependency, included=False, extra_msg=None):
|
680
|
+
self.dependency = dependency
|
681
|
+
self.included = included
|
682
|
+
self.extra_msg = extra_msg
|
683
|
+
|
684
|
+
def __str__(self):
|
685
|
+
msg = ("%s\n\n" % self.extra_msg) if self.extra_msg else ""
|
686
|
+
msg += "You are missing '%s'." % self.dependency
|
687
|
+
if self.included:
|
688
|
+
return (msg + " This should have come with the zip\n"
|
689
|
+
"you downloaded. If you need assistance, please "
|
690
|
+
"contact help@saucelabs.com.")
|
691
|
+
|
692
|
+
msg += " Please install it or contact\nhelp@saucelabs.com for help."
|
693
|
+
try:
|
694
|
+
linux_distro = platform.linux_distribution
|
695
|
+
except AttributeError: # Python 2.5
|
696
|
+
linux_distro = platform.dist
|
697
|
+
if linux_distro()[0].lower() in ['ubuntu', 'debian']:
|
698
|
+
if self.dependency in self.deb_pkg:
|
699
|
+
msg += ("\n\nTo install: sudo aptitude install %s"
|
700
|
+
% self.deb_pkg[self.dependency])
|
701
|
+
return msg
|
702
|
+
|
703
|
+
|
704
|
+
def check_dependencies():
|
705
|
+
if is_windows:
|
706
|
+
if not os.path.exists("plink\plink.exe"):
|
707
|
+
raise MissingDependenciesError("plink\plink.exe", included=True)
|
708
|
+
return
|
709
|
+
|
710
|
+
def check(command):
|
711
|
+
# on unix
|
712
|
+
with tempfile.TemporaryFile() as output:
|
713
|
+
try:
|
714
|
+
subprocess.check_call(command, shell=True, stdout=output,
|
715
|
+
stderr=subprocess.STDOUT)
|
716
|
+
except subprocess.CalledProcessError:
|
717
|
+
dependency = command.split(" ")[0]
|
718
|
+
raise MissingDependenciesError(dependency)
|
719
|
+
output.seek(0)
|
720
|
+
return output.read()
|
721
|
+
|
722
|
+
check("expect -v")
|
723
|
+
|
724
|
+
output = check("ssh -V")
|
725
|
+
if not output.startswith("OpenSSH"):
|
726
|
+
msg = "You have '%s' installed,\nbut %s only supports OpenSSH." % (
|
727
|
+
output.strip(), PRODUCT_NAME)
|
728
|
+
raise MissingDependenciesError("OpenSSH", extra_msg=msg)
|
729
|
+
|
730
|
+
|
731
|
+
def _get_loggable_options(options):
|
732
|
+
ops = dict(options.__dict__)
|
733
|
+
del ops['api_key'] # no need to log the API key
|
734
|
+
return ops
|
735
|
+
|
736
|
+
|
737
|
+
def _run(options):
|
738
|
+
if not options.quiet:
|
739
|
+
print ".---------------------------------------------------."
|
740
|
+
print "| Have questions or need help with Sauce Connect? |"
|
741
|
+
print "| Contact us: http://saucelabs.com/forums |"
|
742
|
+
print "-----------------------------------------------------"
|
743
|
+
logger.info("/ Starting \\")
|
744
|
+
logger.info("%s" % DISPLAY_VERSION)
|
745
|
+
check_version()
|
746
|
+
|
747
|
+
# log the options
|
748
|
+
logger.debug("options: %s" % _get_loggable_options(options))
|
749
|
+
|
750
|
+
metadata = dict(ScriptName=NAME,
|
751
|
+
ScriptRelease=RELEASE,
|
752
|
+
Platform=platform.platform(),
|
753
|
+
PythonVersion=platform.python_version(),
|
754
|
+
OwnerHost=options.host,
|
755
|
+
OwnerPorts=options.ports,
|
756
|
+
Ports=options.tunnel_ports, )
|
757
|
+
logger.debug("metadata: %s" % metadata)
|
758
|
+
|
759
|
+
logger.info("Forwarding: %s:%s -> %s:%s",
|
760
|
+
options.domains, options.tunnel_ports,
|
761
|
+
options.host, options.ports)
|
762
|
+
|
763
|
+
# Initial check of forwarded ports
|
764
|
+
fail_msg = ("!! Are you sure this machine can get to your web server on "
|
765
|
+
"host '%(host)s' listening on port %(port)d? Your tests will "
|
766
|
+
"fail while the server is unreachable.")
|
767
|
+
HealthChecker(options.host, options.ports, fail_msg=fail_msg).check()
|
768
|
+
|
769
|
+
for attempt in xrange(1, RETRY_BOOT_MAX + 1):
|
770
|
+
try:
|
771
|
+
tunnel = TunnelMachine(options.rest_url, options.user,
|
772
|
+
options.api_key, options.domains, metadata)
|
773
|
+
except TunnelMachineError, e:
|
774
|
+
logger.error(e)
|
775
|
+
peace_out(returncode=1) # exits
|
776
|
+
setup_signal_handler(tunnel, options)
|
777
|
+
try:
|
778
|
+
tunnel.ready_wait()
|
779
|
+
break
|
780
|
+
except TunnelMachineError, e:
|
781
|
+
logger.warning(e)
|
782
|
+
if attempt < RETRY_BOOT_MAX:
|
783
|
+
logger.info("Requesting new tunnel")
|
784
|
+
continue
|
785
|
+
logger.error("!! Could not get tunnel host")
|
786
|
+
logger.info("** Please contact help@saucelabs.com")
|
787
|
+
peace_out(tunnel, returncode=1) # exits
|
788
|
+
|
789
|
+
ssh = ReverseSSH(tunnel, options.host, options.ports, options.tunnel_ports,
|
790
|
+
options.debug_ssh)
|
791
|
+
try:
|
792
|
+
ssh.run(options.readyfile)
|
793
|
+
except (ReverseSSHError, TunnelMachineError), e:
|
794
|
+
logger.error(e)
|
795
|
+
peace_out(tunnel) # exits
|
796
|
+
|
797
|
+
|
798
|
+
def main():
|
799
|
+
try:
|
800
|
+
check_dependencies()
|
801
|
+
except MissingDependenciesError, e:
|
802
|
+
print "\n== Missing requirements ==\n"
|
803
|
+
print e
|
804
|
+
raise SystemExit(1)
|
805
|
+
|
806
|
+
options = get_options()
|
807
|
+
setup_logging(options.logfile, options.quiet)
|
808
|
+
|
809
|
+
try:
|
810
|
+
_run(options)
|
811
|
+
except Exception, e:
|
812
|
+
logger.exception("Unhandled exception: %s", str(e))
|
813
|
+
msg = "*** Please send this error to help@saucelabs.com. ***"
|
814
|
+
logger.critical(msg)
|
815
|
+
sys.stderr.write("\noptions: %s\n\n%s\n"
|
816
|
+
% (_get_loggable_options(options), msg))
|
817
|
+
|
818
|
+
|
819
|
+
if __name__ == '__main__':
|
820
|
+
try:
|
821
|
+
main()
|
822
|
+
except Exception, e:
|
823
|
+
msg = "*** Please send this error to help@saucelabs.com. ***"
|
824
|
+
msg = "*" * len(msg) + "\n%s\n" % msg + "*" * len(msg)
|
825
|
+
sys.stderr.write("\n%s\n\n" % msg)
|
826
|
+
raise
|