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 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