sauce 0.11.1 → 0.11.2

Sign up to get free protection for your applications and to get access to all the features.
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