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