sauce 0.7.2 → 0.8.0
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/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
|