sauce 0.11.1 → 0.11.2
Sign up to get free protection for your applications and to get access to all the features.
- data/VERSION +1 -1
- data/lib/sauce/raketasks.rb +21 -19
- data/support/sauce_connect +154 -62
- metadata +4 -4
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.11.
|
1
|
+
0.11.2
|
data/lib/sauce/raketasks.rb
CHANGED
@@ -6,32 +6,34 @@ end
|
|
6
6
|
|
7
7
|
include Sauce::Utilities
|
8
8
|
|
9
|
-
|
10
|
-
namespace :
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
35
|
+
task :selenium => "selenium:sauce"
|
36
|
+
end
|
35
37
|
end
|
36
38
|
|
37
39
|
namespace :test do
|
data/support/sauce_connect
CHANGED
@@ -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 =
|
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.
|
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
|
-
|
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.
|
298
|
+
self.last_tcp_ping = defaultdict(lambda: None)
|
284
299
|
|
285
|
-
def
|
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
|
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
|
-
|
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] =
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
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.
|
341
|
+
self.last_tcp_ping[port] = ping_time
|
307
342
|
logger.warning(self.fail_msg % dict(host=self.host, port=port))
|
308
|
-
if
|
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.
|
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
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
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
|
-
|
495
|
-
|
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(
|
506
|
-
|
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 %
|
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
|
-
|
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
|
-
|
548
|
-
|
549
|
-
|
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
|
-
|
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
|
-
|
801
|
+
version = {}
|
802
|
+
version['expect'] = check("expect -v")
|
723
803
|
|
724
|
-
|
725
|
-
if not
|
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
|
-
|
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
|
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.
|
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
|
-
#
|
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
|
-
|
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:
|
4
|
+
hash: 55
|
5
5
|
prerelease: false
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 11
|
9
|
-
-
|
10
|
-
version: 0.11.
|
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-
|
19
|
+
date: 2010-12-07 00:00:00 -08:00
|
20
20
|
default_executable: sauce
|
21
21
|
dependencies:
|
22
22
|
- !ruby/object:Gem::Dependency
|