sauce 0.11.1 → 0.11.2

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.
Files changed (4) hide show
  1. data/VERSION +1 -1
  2. data/lib/sauce/raketasks.rb +21 -19
  3. data/support/sauce_connect +154 -62
  4. metadata +4 -4
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.1
1
+ 0.11.2
@@ -6,32 +6,34 @@ end
6
6
 
7
7
  include Sauce::Utilities
8
8
 
9
- namespace :spec do
10
- namespace :selenium do
11
- desc "Run the Selenium acceptance tests in spec/selenium using Sauce OnDemand"
12
- task :sauce => spec_prereq do
13
- with_rails_server do
14
- Rake::Task["spec:selenium:runtests"].invoke
9
+ if defined?(Spec)
10
+ namespace :spec do
11
+ namespace :selenium do
12
+ desc "Run the Selenium acceptance tests in spec/selenium using Sauce OnDemand"
13
+ task :sauce => spec_prereq do
14
+ with_rails_server do
15
+ Rake::Task["spec:selenium:runtests"].invoke
16
+ end
15
17
  end
16
- end
17
18
 
18
- desc "Run the Selenium acceptance tests in spec/selenium using a local Selenium server"
19
- task :local => spec_prereq do
20
- with_rails_server do
21
- with_selenium_rc do
22
- Rake::Task["spec:selenium:runtests"].invoke
19
+ desc "Run the Selenium acceptance tests in spec/selenium using a local Selenium server"
20
+ task :local => spec_prereq do
21
+ with_rails_server do
22
+ with_selenium_rc do
23
+ Rake::Task["spec:selenium:runtests"].invoke
24
+ end
23
25
  end
24
26
  end
25
- end
26
27
 
27
- desc "" # Hide it from rake -T
28
- Spec::Rake::SpecTask.new :runtests do |t|
29
- t.spec_opts = ['--options', "\"#{RAILS_ROOT}/spec/spec.opts\""]
30
- t.spec_files = FileList["spec/selenium/**/*_spec.rb"]
28
+ desc "" # Hide it from rake -T
29
+ Spec::Rake::SpecTask.new :runtests do |t|
30
+ t.spec_opts = ['--options', "\"#{RAILS_ROOT}/spec/spec.opts\""]
31
+ t.spec_files = FileList["spec/selenium/**/*_spec.rb"]
32
+ end
31
33
  end
32
- end
33
34
 
34
- task :selenium => "selenium:sauce"
35
+ task :selenium => "selenium:sauce"
36
+ end
35
37
  end
36
38
 
37
39
  namespace :test do
@@ -3,12 +3,14 @@
3
3
  from __future__ import with_statement
4
4
 
5
5
  # TODO:
6
+ # * ?? Use "-o StrictHostKeyChecking no"
6
7
  # * Move to REST API v1
7
8
  # * windows: SSH link healthcheck (PuTTY session file hack?)
8
9
  # * Daemonizing
9
10
  # * issue: windows: no os.fork()
10
11
  # * issue: unix: null file descriptors causes Expect script to fail
11
12
  # * Renew tunnel lease (backend not implemented)
13
+ # * Check tunnel machine ports are open (backend not implemented)
12
14
  #
13
15
 
14
16
  import os
@@ -18,6 +20,7 @@ import optparse
18
20
  import logging
19
21
  import logging.handlers
20
22
  import signal
23
+ import atexit
21
24
  import httplib
22
25
  import urllib2
23
26
  import subprocess
@@ -36,7 +39,7 @@ except ImportError:
36
39
  import simplejson as json # Python 2.5 dependency
37
40
 
38
41
  NAME = "sauce_connect"
39
- RELEASE = 17
42
+ RELEASE = 20
40
43
  DISPLAY_VERSION = "%s release %s" % (NAME, RELEASE)
41
44
  PRODUCT_NAME = u"Sauce Connect"
42
45
  VERSIONS_URL = "http://saucelabs.com/versions.json"
@@ -49,6 +52,8 @@ REST_POLL_WAIT = 3
49
52
  RETRY_SSH_MAX = 4
50
53
  HEALTH_CHECK_INTERVAL = 15
51
54
  HEALTH_CHECK_FAIL = 5 * 60 # no good check after this amount of time == fail
55
+ LATENCY_LOG = 150 # log when making connections takes this many ms
56
+ LATENCY_WARNING = 350 # warn when making connections takes this many ms
52
57
  SIGNALS_RECV_MAX = 4 # used with --allow-unclean-exit
53
58
 
54
59
  is_windows = platform.system().lower() == "windows"
@@ -124,8 +129,7 @@ class TunnelMachine(object):
124
129
  try:
125
130
  result = f(*args, **kwargs)
126
131
  if previous_failed:
127
- logger.info(
128
- "Connection succeeded")
132
+ logger.info("Connection succeeded")
129
133
  return result
130
134
  except (HTTPResponseError,
131
135
  urllib2.URLError, httplib.HTTPException,
@@ -140,6 +144,10 @@ class TunnelMachine(object):
140
144
  previous_failed = True
141
145
  logger.debug("Retrying in %ds", RETRY_REST_WAIT)
142
146
  time.sleep(RETRY_REST_WAIT)
147
+ except Exception, e:
148
+ raise TunnelMachineError(
149
+ "An error occurred while contacting Sauce Labs REST "
150
+ "API (%s). Please contact help@saucelabs.com." % str(e))
143
151
  return wrapper
144
152
 
145
153
  @_retry_rest_api
@@ -207,7 +215,7 @@ class TunnelMachine(object):
207
215
  "or value for '%s'" % key)
208
216
  self.id = doc['id']
209
217
  self.url = "%s/%s" % (self.base_url, self.id)
210
- logger.debug("Provisioned tunnel host: %s" % self.id)
218
+ logger.info("Tunnel host is provisioned (%s)" % self.id)
211
219
 
212
220
  def ready_wait(self):
213
221
  """Wait for the machine to reach the 'running' state."""
@@ -236,7 +244,12 @@ class TunnelMachine(object):
236
244
  logger.info("Shutting down tunnel host (please wait)")
237
245
  logger.debug("Tunnel host ID: %s" % self.id)
238
246
 
239
- doc = self._get_delete_doc(self.url)
247
+ try:
248
+ doc = self._get_delete_doc(self.url)
249
+ except TunnelMachineError:
250
+ logger.warning("Unable to shut down tunnel host")
251
+ self.is_shutdown = True # fuhgeddaboudit
252
+ return
240
253
  assert doc.get('ok')
241
254
 
242
255
  previous_status = None
@@ -271,6 +284,8 @@ class HealthCheckFail(Exception):
271
284
 
272
285
  class HealthChecker(object):
273
286
 
287
+ latency_log = LATENCY_LOG
288
+
274
289
  def __init__(self, host, ports, fail_msg=None):
275
290
  """fail_msg can include '%(host)s' and '%(port)d'"""
276
291
  self.host = host
@@ -280,34 +295,54 @@ class HealthChecker(object):
280
295
  "can not get to %(host)s:%(port)d.")
281
296
  self.ports = frozenset(int(p) for p in ports)
282
297
  self.last_tcp_connect = defaultdict(time.time)
283
- self.previous_failed = defaultdict(lambda: False)
298
+ self.last_tcp_ping = defaultdict(lambda: None)
284
299
 
285
- def _tcp_connected(self, port):
300
+ def _tcp_ping(self, port):
286
301
  with closing(socket.socket()) as sock:
302
+ start_time = time.time()
287
303
  try:
288
304
  sock.connect((self.host, port))
289
- return True
305
+ return int(1000 * (time.time() - start_time))
290
306
  except (socket.gaierror, socket.error), e:
291
307
  logger.warning("Could not connect to %s:%s (%s)",
292
308
  self.host, port, str(e))
293
- return False
294
309
 
295
310
  def check(self):
311
+ now = time.time()
296
312
  for port in self.ports:
297
- if self._tcp_connected(port):
313
+ ping_time = self._tcp_ping(port)
314
+ if ping_time is not None:
298
315
  # 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
316
+ self.last_tcp_connect[port] = now
317
+ result = (self.host, port, ping_time)
318
+
319
+ if ping_time >= self.latency_log:
320
+ logger.debug("Connected to %s:%s in in %dms" % result)
321
+
322
+ if ping_time >= LATENCY_WARNING:
323
+ if (self.last_tcp_ping[port] is None
324
+ or self.last_tcp_ping[port] < LATENCY_WARNING):
325
+ logger.warn("High latency to %s:%s (took %dms to "
326
+ "connect); tests may run slowly" % result)
327
+
328
+ if (ping_time < (LATENCY_WARNING / 2)
329
+ and self.last_tcp_ping[port]
330
+ and self.last_tcp_ping[port] >= LATENCY_WARNING):
331
+ logger.info("Latency to %s:%s has lowered (took %dms to "
332
+ "connect)" % result)
333
+
334
+ if self.last_tcp_ping[port] is None:
335
+ logger.info("Succesfully connected to %s:%s in %dms" % result)
336
+
337
+ self.last_tcp_ping[port] = ping_time
304
338
  continue
339
+
305
340
  # TCP connection failed
306
- self.previous_failed[port] = True
341
+ self.last_tcp_ping[port] = ping_time
307
342
  logger.warning(self.fail_msg % dict(host=self.host, port=port))
308
- if time.time() - self.last_tcp_connect[port] > HEALTH_CHECK_FAIL:
343
+ if now - self.last_tcp_connect[port] > HEALTH_CHECK_FAIL:
309
344
  raise HealthCheckFail(
310
- "Could not connect to %s:%s for %s seconds"
345
+ "Could not connect to %s:%s for over %s seconds"
311
346
  % (self.host, port, HEALTH_CHECK_FAIL))
312
347
 
313
348
 
@@ -401,10 +436,13 @@ class ReverseSSH(object):
401
436
 
402
437
  # ssh process is running
403
438
  announced_running = False
439
+
440
+ # setup recurring healthchecks
404
441
  forwarded_health = HealthChecker(self.host, self.ports)
405
442
  tunnel_health = HealthChecker(host=self.tunnel.host, ports=[22],
406
443
  fail_msg="!! Your tests may fail because your network can not get "
407
444
  "to the tunnel host (%s:%d)." % (self.tunnel.host, 22))
445
+
408
446
  start_time = int(time.time())
409
447
  while self.proc.poll() is None:
410
448
  now = int(time.time())
@@ -463,14 +501,16 @@ class ReverseSSH(object):
463
501
 
464
502
  def stop(self):
465
503
  self._rm_readyfile()
466
- self._log_output()
467
- if is_windows or not self.proc:
504
+ if not self.proc or self.proc.poll() is not None: # not running, done
468
505
  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
506
+
507
+ if not is_windows: # windows no have kill()
508
+ try:
509
+ os.kill(self.proc.pid, signal.SIGHUP)
510
+ logger.debug("Sent SIGHUP to PID %d", self.proc.pid)
511
+ except OSError:
512
+ pass
513
+ self._log_output()
474
514
 
475
515
  def run(self, readyfile=None):
476
516
  clean_exit = False
@@ -481,41 +521,51 @@ class ReverseSSH(object):
481
521
  # TODO: revisit if server uses OpenSSH instead of Twisted SSH
482
522
  if self._start_reverse_ssh(readyfile) == 0:
483
523
  clean_exit = True
524
+ break
525
+ if attempt < RETRY_SSH_MAX:
526
+ logger.debug("Will restart SSH in 3 seconds")
527
+ time.sleep(3) # wait a bit for old connections to close
484
528
  self._rm_readyfile()
485
529
  if not clean_exit:
486
530
  raise ReverseSSHError(
487
531
  "SSH process errored %d times (bad network?)" % attempt)
488
532
 
489
533
 
490
- def peace_out(tunnel=None, returncode=0):
534
+ def peace_out(tunnel=None, returncode=0, atexit=False):
491
535
  """Shutdown the tunnel and raise SystemExit."""
492
536
  if tunnel:
493
537
  tunnel.shutdown()
494
- logger.info("\ Exiting /")
495
- raise SystemExit(returncode)
538
+ if not atexit:
539
+ logger.info("\ Exiting /")
540
+ raise SystemExit(returncode)
541
+ else:
542
+ logger.debug("--- fin ---")
496
543
 
497
544
 
498
545
  def setup_signal_handler(tunnel, options):
499
546
  signal_count = defaultdict(int)
547
+ signal_name = {}
500
548
 
501
549
  def sig_handler(signum, frame):
502
550
  if options.allow_unclean_exit:
503
551
  signal_count[signum] += 1
504
552
  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])
553
+ logger.info(
554
+ "Received %s too many times (%d). Making unclean "
555
+ "exit now!", signal_name[signum], signal_count[signum])
507
556
  raise SystemExit(1)
508
- logger.info("Received signal %d", signum)
557
+ logger.info("Received signal %s", signal_name[signum])
509
558
  peace_out(tunnel) # exits
510
559
 
511
560
  # TODO: ?? remove SIGTERM when we implement tunnel leases
512
561
  if is_windows:
513
- # TODO: What do these Windows signals really mean?
514
562
  supported_signals = ["SIGABRT", "SIGBREAK", "SIGINT", "SIGTERM"]
515
563
  else:
516
564
  supported_signals = ["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM"]
517
565
  for sig in supported_signals:
518
- signal.signal(getattr(signal, sig), sig_handler)
566
+ signum = getattr(signal, sig)
567
+ signal_name[signum] = sig
568
+ signal.signal(signum, sig_handler)
519
569
 
520
570
 
521
571
  def check_version():
@@ -544,9 +594,13 @@ def check_version():
544
594
  logger.info(failed_msg)
545
595
  return
546
596
  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)
597
+ msgs = ["** This version of %s is outdated." % PRODUCT_NAME,
598
+ "** Please update with %s" % download_url]
599
+ for update_msg in msgs:
600
+ logger.warning(update_msg)
601
+ for update_msg in msgs:
602
+ sys.stderr.write("%s\n" % update_msg)
603
+ time.sleep(15)
550
604
 
551
605
 
552
606
  def setup_logging(logfile=None, quiet=False):
@@ -569,6 +623,38 @@ def setup_logging(logfile=None, quiet=False):
569
623
  logger.addHandler(fileout)
570
624
 
571
625
 
626
+ def check_domains(domains):
627
+ """Display error and exit script if any requested domains are invalid."""
628
+
629
+ for dom in domains:
630
+ # no URLs
631
+ if '/' in dom:
632
+ sys.stderr.write(
633
+ "Error: Domain contains illegal character '/' in it.\n")
634
+ print " Did you use a URL instead of just the domain?\n"
635
+ print "Examples: -d example.com -d '*.example.com' -d cdn.example.org"
636
+ print
637
+ raise SystemExit(1)
638
+
639
+ # no numerical addresses
640
+ if all(map(lambda c: c.isdigit() or c == '.', dom)):
641
+ sys.stderr.write("Error: Domain must be a hostname not an IP\n")
642
+ print
643
+ print "Examples: -d example.com -d '*.example.com' -d cdn.example.org"
644
+ print
645
+ raise SystemExit(1)
646
+
647
+ # need a dot and 2 char TLD
648
+ # NOTE: if this restriction is relaxed, still check for "localhost"
649
+ if '.' not in dom or len(dom.rpartition('.')[2]) < 2:
650
+ sys.stderr.write(
651
+ "Error: Domain requires a TLD of 2 characters or more\n")
652
+ print
653
+ print "Example: -d example.tld -d '*.example.tld' -d cdn.example.tld"
654
+ print
655
+ raise SystemExit(1)
656
+
657
+
572
658
  def get_options():
573
659
  usage = """
574
660
  Usage: %(name)s -u <user> -k <api_key> -s <webserver> -d <domain> [options]
@@ -629,6 +715,9 @@ Performance tip:
629
715
  og.add_option("--rest-url", default="https://saucelabs.com/rest",
630
716
  help="[%default]")
631
717
  og.add_option("--debug-ssh", action="store_true", default=False)
718
+ og.add_option("--latency-log", type=int, default=LATENCY_LOG,
719
+ help="Threshold above which latency (ms) will be "
720
+ "logged. [%default]")
632
721
  og.add_option("--allow-unclean-exit", action="store_true", default=False)
633
722
  op.add_option_group(og)
634
723
 
@@ -658,17 +747,7 @@ Performance tip:
658
747
  op.print_help()
659
748
  raise SystemExit(1)
660
749
 
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
-
750
+ check_domains(options.domains)
672
751
  return options
673
752
 
674
753
 
@@ -717,16 +796,19 @@ def check_dependencies():
717
796
  dependency = command.split(" ")[0]
718
797
  raise MissingDependenciesError(dependency)
719
798
  output.seek(0)
720
- return output.read()
799
+ return output.read().strip()
721
800
 
722
- check("expect -v")
801
+ version = {}
802
+ version['expect'] = check("expect -v")
723
803
 
724
- output = check("ssh -V")
725
- if not output.startswith("OpenSSH"):
804
+ version['ssh'] = check("ssh -V")
805
+ if not version['ssh'].startswith("OpenSSH"):
726
806
  msg = "You have '%s' installed,\nbut %s only supports OpenSSH." % (
727
- output.strip(), PRODUCT_NAME)
807
+ version['ssh'], PRODUCT_NAME)
728
808
  raise MissingDependenciesError("OpenSSH", extra_msg=msg)
729
809
 
810
+ return version
811
+
730
812
 
731
813
  def _get_loggable_options(options):
732
814
  ops = dict(options.__dict__)
@@ -734,7 +816,7 @@ def _get_loggable_options(options):
734
816
  return ops
735
817
 
736
818
 
737
- def _run(options):
819
+ def run(options, dependency_versions=None):
738
820
  if not options.quiet:
739
821
  print ".---------------------------------------------------."
740
822
  print "| Have questions or need help with Sauce Connect? |"
@@ -744,9 +826,6 @@ def _run(options):
744
826
  logger.info("%s" % DISPLAY_VERSION)
745
827
  check_version()
746
828
 
747
- # log the options
748
- logger.debug("options: %s" % _get_loggable_options(options))
749
-
750
829
  metadata = dict(ScriptName=NAME,
751
830
  ScriptRelease=RELEASE,
752
831
  Platform=platform.platform(),
@@ -754,13 +833,19 @@ def _run(options):
754
833
  OwnerHost=options.host,
755
834
  OwnerPorts=options.ports,
756
835
  Ports=options.tunnel_ports, )
836
+ if dependency_versions:
837
+ metadata['DependencyVersions'] = dependency_versions
838
+
839
+ logger.debug("System is %s hours off UTC" %
840
+ (- (time.timezone, time.altzone)[time.daylight] / 3600.))
841
+ logger.debug("options: %s" % _get_loggable_options(options))
757
842
  logger.debug("metadata: %s" % metadata)
758
843
 
759
- logger.info("Forwarding: %s:%s -> %s:%s",
760
- options.domains, options.tunnel_ports,
761
- options.host, options.ports)
844
+ logger.info("Forwarding: %s:%s -> %s:%s", options.domains,
845
+ options.tunnel_ports, options.host, options.ports)
762
846
 
763
- # Initial check of forwarded ports
847
+ # Setup HealthChecker latency and make initial check of forwarded ports
848
+ HealthChecker.latency_log = options.latency_log
764
849
  fail_msg = ("!! Are you sure this machine can get to your web server on "
765
850
  "host '%(host)s' listening on port %(port)d? Your tests will "
766
851
  "fail while the server is unreachable.")
@@ -774,6 +859,7 @@ def _run(options):
774
859
  logger.error(e)
775
860
  peace_out(returncode=1) # exits
776
861
  setup_signal_handler(tunnel, options)
862
+ atexit.register(peace_out, tunnel, atexit=True)
777
863
  try:
778
864
  tunnel.ready_wait()
779
865
  break
@@ -796,8 +882,14 @@ def _run(options):
796
882
 
797
883
 
798
884
  def main():
885
+ # more complicated so this works on old Python
886
+ pyver = float("%s.%s" % tuple(platform.python_version().split('.')[:2]))
887
+ if pyver < 2.5:
888
+ print "%s requires Python 2.5 (2006) or newer." % PRODUCT_NAME
889
+ raise SystemExit(1)
890
+
799
891
  try:
800
- check_dependencies()
892
+ dependency_versions = check_dependencies()
801
893
  except MissingDependenciesError, e:
802
894
  print "\n== Missing requirements ==\n"
803
895
  print e
@@ -807,7 +899,7 @@ def main():
807
899
  setup_logging(options.logfile, options.quiet)
808
900
 
809
901
  try:
810
- _run(options)
902
+ run(options, dependency_versions)
811
903
  except Exception, e:
812
904
  logger.exception("Unhandled exception: %s", str(e))
813
905
  msg = "*** Please send this error to help@saucelabs.com. ***"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sauce
3
3
  version: !ruby/object:Gem::Version
4
- hash: 49
4
+ hash: 55
5
5
  prerelease: false
6
6
  segments:
7
7
  - 0
8
8
  - 11
9
- - 1
10
- version: 0.11.1
9
+ - 2
10
+ version: 0.11.2
11
11
  platform: ruby
12
12
  authors:
13
13
  - Sean Grove
@@ -16,7 +16,7 @@ autorequire:
16
16
  bindir: bin
17
17
  cert_chain: []
18
18
 
19
- date: 2010-11-19 00:00:00 -08:00
19
+ date: 2010-12-07 00:00:00 -08:00
20
20
  default_executable: sauce
21
21
  dependencies:
22
22
  - !ruby/object:Gem::Dependency