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 CHANGED
@@ -1 +1 @@
1
- 0.7.2
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
- cmd = CmdParse::CommandParser.new(true, true)
12
- cmd.program_name = "sauce "
13
- cmd.program_version = [0, 1, 0]
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.add_command(CmdParse::HelpCommand.new)
17
+ cmd = CmdParse::CommandParser.new(true, true)
18
+ cmd.program_name = "sauce "
19
+ cmd.program_version = [0, 1, 0]
16
20
 
17
- # configure
18
- configure = CmdParse::Command.new('configure', false)
19
- configure.short_desc = "Configure Sauce OnDemand credentials"
20
- configure.set_execution_block do |args|
21
- if args.length < 2:
22
- puts "Usage: sauce configure USERNAME ACCESS_KEY"
23
- exit 1
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
- username = args[0]
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
- puts "Let's create a new account!"
38
- print "Username: "
39
- username = $stdin.gets.chomp
40
- print "password: "
41
- password = $stdin.gets.chomp
42
- print "password confirmation: "
43
- password_confirmation = $stdin.gets.chomp
44
- print "email: "
45
- email = $stdin.gets.chomp
46
- print "Full name: "
47
- name = $stdin.gets.chomp
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
- # TODO: Add error handling, of course
50
- result = RestClient.post "http://saucelabs.com/rest/v1/users",
51
- {
52
- :username => username,
53
- :password => password,
54
- :password_confirmation => password_confirmation,
55
- :email => email,
56
- :token => "c8eb3e2645005bcbbce7e2c208c6b7a71555d908",
57
- :name => name
58
- }.to_json,
59
- :content_type => :json, :accept => :json
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
- puts result.inspect
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
@@ -5,3 +5,4 @@ require 'sauce/client'
5
5
  require 'sauce/config'
6
6
  require 'sauce/selenium'
7
7
  require 'sauce/integrations'
8
+ require 'sauce/connect'
@@ -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
@@ -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
@@ -0,0 +1,7 @@
1
+ require File.join(File.dirname(File.expand_path(File.dirname(__FILE__))), "test", "helper")
2
+
3
+ describe "The login form", :type => :selenium do
4
+ it "exists" do
5
+ selenium.open "/"
6
+ end
7
+ end
@@ -1,12 +1,4 @@
1
- require 'helper'
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