test-kitchen 1.0.0.alpha.4 → 1.0.0.alpha.5
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.md +16 -0
- data/bin/kitchen +2 -1
- data/lib/kitchen/busser.rb +34 -36
- data/lib/kitchen/cli.rb +5 -3
- data/lib/kitchen/driver/base.rb +3 -3
- data/lib/kitchen/driver/ssh_base.rb +14 -7
- data/lib/kitchen/driver.rb +4 -1
- data/lib/kitchen/errors.rb +74 -1
- data/lib/kitchen/instance.rb +18 -3
- data/lib/kitchen/loader/yaml.rb +5 -2
- data/lib/kitchen/logger.rb +1 -1
- data/lib/kitchen/version.rb +1 -1
- data/lib/kitchen.rb +6 -1
- data/spec/kitchen/loader/yaml_spec.rb +7 -0
- metadata +2 -2
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,14 @@
|
|
1
|
+
## 1.0.0.alpha.5 / 2013-04-23
|
2
|
+
|
3
|
+
### Improvements
|
4
|
+
|
5
|
+
* Pull request [#81][]: Clean up error reporting in CLI output. ([@fnichol][])
|
6
|
+
* Pull request [#76][]: Swap out shell-based kb for Ruby-based Busser gem. ([@fnichol][])
|
7
|
+
* Pull request [#82][], issue [#61][]: Install Omnibus package via either wget or curl. ([@fnichol][])
|
8
|
+
* Catch YAML data merging errors as user errors. ([@fnichol][])
|
9
|
+
* Issue [#80][]: Add a more helpful error message when a driver could not be loaded. ([@fnichol][])
|
10
|
+
|
11
|
+
|
1
12
|
## 1.0.0.alpha.4 / 2013-04-10
|
2
13
|
|
3
14
|
### Bug fixes
|
@@ -70,11 +81,16 @@
|
|
70
81
|
The initial release.
|
71
82
|
|
72
83
|
<!--- The following link definition list is generated by PimpMyChangelog --->
|
84
|
+
[#61]: https://github.com/opscode/test/issues/61
|
73
85
|
[#64]: https://github.com/opscode/test/issues/64
|
74
86
|
[#65]: https://github.com/opscode/test/issues/65
|
75
87
|
[#71]: https://github.com/opscode/test/issues/71
|
76
88
|
[#73]: https://github.com/opscode/test/issues/73
|
77
89
|
[#74]: https://github.com/opscode/test/issues/74
|
90
|
+
[#76]: https://github.com/opscode/test/issues/76
|
91
|
+
[#80]: https://github.com/opscode/test/issues/80
|
92
|
+
[#81]: https://github.com/opscode/test/issues/81
|
93
|
+
[#82]: https://github.com/opscode/test/issues/82
|
78
94
|
[@ChrisLundquist]: https://github.com/ChrisLundquist
|
79
95
|
[@bryanwb]: https://github.com/bryanwb
|
80
96
|
[@fnichol]: https://github.com/fnichol
|
data/bin/kitchen
CHANGED
data/lib/kitchen/busser.rb
CHANGED
@@ -18,18 +18,17 @@
|
|
18
18
|
|
19
19
|
require 'base64'
|
20
20
|
require 'digest'
|
21
|
-
require 'net/https'
|
22
21
|
|
23
22
|
module Kitchen
|
24
23
|
|
25
|
-
# Command string generator to interface with
|
26
|
-
#
|
27
|
-
#
|
24
|
+
# Command string generator to interface with Busser. The commands that are
|
25
|
+
# generated are safe to pass to an SSH command or as an unix command
|
26
|
+
# argument (escaped in single quotes).
|
28
27
|
#
|
29
28
|
# @author Fletcher Nichol <fnichol@nichol.ca>
|
30
29
|
class Busser
|
31
30
|
|
32
|
-
# Constructs a new
|
31
|
+
# Constructs a new Busser command generator, given a suite name.
|
33
32
|
#
|
34
33
|
# @param [String] suite_name name of suite on which to operate
|
35
34
|
# (**Required**)
|
@@ -43,8 +42,8 @@ module Kitchen
|
|
43
42
|
@use_sudo = opts[:use_sudo]
|
44
43
|
end
|
45
44
|
|
46
|
-
# Returns a command string which installs
|
47
|
-
#
|
45
|
+
# Returns a command string which installs Busser, and installs all
|
46
|
+
# required Busser plugins for the suite.
|
48
47
|
#
|
49
48
|
# If no work needs to be performed, for example if there are no tests for
|
50
49
|
# the given suite, then `nil` will be returned.
|
@@ -56,11 +55,11 @@ module Kitchen
|
|
56
55
|
nil
|
57
56
|
else
|
58
57
|
<<-INSTALL_CMD.gsub(/^ {10}/, '')
|
59
|
-
#{sudo}#{
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
#{sudo}#{
|
58
|
+
if ! #{sudo}#{ruby_binpath}/gem list busser -i >/dev/null ; then
|
59
|
+
#{sudo}#{ruby_binpath}/gem install #{busser_gem} --no-rdoc --no-ri
|
60
|
+
fi
|
61
|
+
#{sudo}#{ruby_binpath}/busser setup
|
62
|
+
#{sudo}#{busser_bin} plugin install #{plugins.join(' ')}
|
64
63
|
INSTALL_CMD
|
65
64
|
end
|
66
65
|
end
|
@@ -78,13 +77,13 @@ module Kitchen
|
|
78
77
|
nil
|
79
78
|
else
|
80
79
|
<<-INSTALL_CMD.gsub(/^ {10}/, '')
|
81
|
-
#{sudo}#{
|
80
|
+
#{sudo}#{busser_bin} suite cleanup
|
82
81
|
#{local_suite_files.map { |f| stream_file(f, remote_file(f)) }.join}
|
83
82
|
INSTALL_CMD
|
84
83
|
end
|
85
84
|
end
|
86
85
|
|
87
|
-
# Returns a command string which runs all
|
86
|
+
# Returns a command string which runs all Busser suite tests for the suite.
|
88
87
|
#
|
89
88
|
# If no work needs to be performed, for example if there are no tests for
|
90
89
|
# the given suite, then `nil` will be returned.
|
@@ -92,34 +91,23 @@ module Kitchen
|
|
92
91
|
# @return [String] a command string to run the test suites, or nil if no
|
93
92
|
# work needs to be performed
|
94
93
|
def run_cmd
|
95
|
-
@run_cmd ||= local_suite_files.empty? ? nil : "#{sudo}#{
|
94
|
+
@run_cmd ||= local_suite_files.empty? ? nil : "#{sudo}#{busser_bin} test"
|
96
95
|
end
|
97
96
|
|
98
97
|
private
|
99
98
|
|
100
|
-
INSTALL_URL = "https://raw.github.com/opscode/kb/go".freeze
|
101
99
|
DEFAULT_RUBY_BINPATH = "/opt/chef/embedded/bin".freeze
|
102
|
-
|
100
|
+
DEFAULT_BUSSER_ROOT = "/opt/busser".freeze
|
103
101
|
DEFAULT_TEST_ROOT = File.join(Dir.pwd, "test/integration").freeze
|
104
102
|
|
105
103
|
def validate_options(suite_name)
|
106
104
|
raise ClientError, "Busser#new requires a suite_name" if suite_name.nil?
|
107
105
|
end
|
108
106
|
|
109
|
-
def install_script
|
110
|
-
@install_script ||= begin
|
111
|
-
uri = URI.parse(INSTALL_URL)
|
112
|
-
http = Net::HTTP.new(uri.host, 443)
|
113
|
-
http.use_ssl = true
|
114
|
-
response = http.request(Net::HTTP::Get.new(uri.path))
|
115
|
-
response.body
|
116
|
-
end
|
117
|
-
end
|
118
|
-
|
119
107
|
def plugins
|
120
108
|
Dir.glob(File.join(test_root, @suite_name, "*")).select { |d|
|
121
109
|
File.directory?(d) && File.basename(d) != "data_bags"
|
122
|
-
}.map { |d| File.basename(d) }.sort.uniq
|
110
|
+
}.map { |d| "busser-#{File.basename(d)}" }.sort.uniq
|
123
111
|
end
|
124
112
|
|
125
113
|
def local_suite_files
|
@@ -130,18 +118,24 @@ module Kitchen
|
|
130
118
|
|
131
119
|
def remote_file(file)
|
132
120
|
local_prefix = File.join(test_root, @suite_name)
|
133
|
-
"$(#{
|
121
|
+
"$(#{busser_bin} suite path)/".concat(file.sub(%r{^#{local_prefix}/}, ''))
|
134
122
|
end
|
135
123
|
|
136
124
|
def stream_file(local_path, remote_path)
|
137
125
|
local_file = IO.read(local_path)
|
138
126
|
md5 = Digest::MD5.hexdigest(local_file)
|
139
|
-
perms = sprintf("%o", File.stat(local_path).mode)[
|
140
|
-
|
127
|
+
perms = sprintf("%o", File.stat(local_path).mode)[2, 4]
|
128
|
+
stream_cmd = [
|
129
|
+
busser_bin,
|
130
|
+
"deserialize",
|
131
|
+
"--destination=#{remote_path}",
|
132
|
+
"--md5sum=#{md5}",
|
133
|
+
"--perms=#{perms}"
|
134
|
+
].join(" ")
|
141
135
|
|
142
136
|
<<-STREAMFILE.gsub(/^ {8}/, '')
|
143
137
|
echo "Uploading #{remote_path} (mode=#{perms})"
|
144
|
-
cat <<"__EOFSTREAM__" | #{sudo}#{
|
138
|
+
cat <<"__EOFSTREAM__" | #{sudo}#{stream_cmd}
|
145
139
|
#{Base64.encode64(local_file)}
|
146
140
|
__EOFSTREAM__
|
147
141
|
STREAMFILE
|
@@ -151,12 +145,16 @@ module Kitchen
|
|
151
145
|
@use_sudo ? "sudo " : ""
|
152
146
|
end
|
153
147
|
|
154
|
-
def
|
155
|
-
|
148
|
+
def ruby_binpath
|
149
|
+
DEFAULT_RUBY_BINPATH
|
150
|
+
end
|
151
|
+
|
152
|
+
def busser_bin
|
153
|
+
File.join(DEFAULT_BUSSER_ROOT, "bin/busser")
|
156
154
|
end
|
157
155
|
|
158
|
-
def
|
159
|
-
|
156
|
+
def busser_gem
|
157
|
+
"busser"
|
160
158
|
end
|
161
159
|
|
162
160
|
def test_root
|
data/lib/kitchen/cli.rb
CHANGED
@@ -39,12 +39,12 @@ module Kitchen
|
|
39
39
|
def initialize(*args)
|
40
40
|
super
|
41
41
|
$stdout.sync = true
|
42
|
+
Kitchen.logger = Kitchen.default_file_logger
|
42
43
|
@config = Kitchen::Config.new(
|
43
44
|
:loader => Kitchen::Loader::YAML.new(ENV['KITCHEN_YAML']),
|
44
45
|
:log_level => ENV['KITCHEN_LOG'] && ENV['KITCHEN_LOG'].downcase.to_sym,
|
45
46
|
:supervised => false
|
46
47
|
)
|
47
|
-
Kitchen.logger = Kitchen.default_file_logger
|
48
48
|
end
|
49
49
|
|
50
50
|
desc "list [(all|<REGEX>)]", "List all instances"
|
@@ -304,12 +304,14 @@ module Kitchen
|
|
304
304
|
when nil then set_color("<Not Created>", :red)
|
305
305
|
else set_color("<Unknown>", :white)
|
306
306
|
end
|
307
|
-
[
|
307
|
+
[instance.name, action]
|
308
308
|
end
|
309
309
|
|
310
310
|
def update_config!
|
311
311
|
if options[:log_level]
|
312
|
-
|
312
|
+
level = options[:log_level].downcase.to_sym
|
313
|
+
@config.log_level = level
|
314
|
+
Kitchen.logger.level = Util.to_logger_level(level)
|
313
315
|
end
|
314
316
|
end
|
315
317
|
|
data/lib/kitchen/driver/base.rb
CHANGED
@@ -146,15 +146,15 @@ module Kitchen
|
|
146
146
|
super(cmd, base_options)
|
147
147
|
end
|
148
148
|
|
149
|
-
def
|
149
|
+
def busser_setup_cmd
|
150
150
|
busser.setup_cmd
|
151
151
|
end
|
152
152
|
|
153
|
-
def
|
153
|
+
def busser_sync_cmd
|
154
154
|
busser.sync_cmd
|
155
155
|
end
|
156
156
|
|
157
|
-
def
|
157
|
+
def busser_run_cmd
|
158
158
|
busser.run_cmd
|
159
159
|
end
|
160
160
|
|
@@ -47,17 +47,17 @@ module Kitchen
|
|
47
47
|
def setup(state)
|
48
48
|
ssh_args = build_ssh_args(state)
|
49
49
|
|
50
|
-
if
|
51
|
-
ssh(ssh_args,
|
50
|
+
if busser_setup_cmd
|
51
|
+
ssh(ssh_args, busser_setup_cmd)
|
52
52
|
end
|
53
53
|
end
|
54
54
|
|
55
55
|
def verify(state)
|
56
56
|
ssh_args = build_ssh_args(state)
|
57
57
|
|
58
|
-
if
|
59
|
-
ssh(ssh_args,
|
60
|
-
ssh(ssh_args,
|
58
|
+
if busser_run_cmd
|
59
|
+
ssh(ssh_args, busser_sync_cmd)
|
60
|
+
ssh(ssh_args, busser_run_cmd)
|
61
61
|
end
|
62
62
|
end
|
63
63
|
|
@@ -98,6 +98,7 @@ module Kitchen
|
|
98
98
|
end
|
99
99
|
|
100
100
|
def install_omnibus(ssh_args)
|
101
|
+
url = "https://www.opscode.com/chef/install.sh"
|
101
102
|
flag = config[:require_chef_omnibus]
|
102
103
|
version = if flag.is_a?(String) && flag != "latest"
|
103
104
|
"-s -- -v #{flag.downcase}"
|
@@ -115,8 +116,14 @@ module Kitchen
|
|
115
116
|
|
116
117
|
if [ ! -d "/opt/chef" ] || should_update_chef ; then
|
117
118
|
echo "-----> Installing Chef Omnibus (#{flag})"
|
118
|
-
|
119
|
-
| sudo bash #{version}
|
119
|
+
if command -v wget &>/dev/null ; then
|
120
|
+
wget #{url} -O - | sudo bash #{version}
|
121
|
+
elif command -v curl &>/dev/null ; then
|
122
|
+
curl -sSL #{url} | sudo bash #{version}
|
123
|
+
else
|
124
|
+
echo ">>>>>> Neither wget nor curl found on this instance."
|
125
|
+
exit 1
|
126
|
+
fi
|
120
127
|
fi
|
121
128
|
INSTALL
|
122
129
|
end
|
data/lib/kitchen/driver.rb
CHANGED
@@ -39,7 +39,10 @@ module Kitchen
|
|
39
39
|
rescue UserError
|
40
40
|
raise
|
41
41
|
rescue LoadError, NameError
|
42
|
-
raise ClientError,
|
42
|
+
raise ClientError,
|
43
|
+
"Could not load the '#{plugin}' driver from the load path." +
|
44
|
+
" Please ensure that your driver is installed as a gem or included" +
|
45
|
+
" in your Gemfile if using Bundler."
|
43
46
|
end
|
44
47
|
end
|
45
48
|
end
|
data/lib/kitchen/errors.rb
CHANGED
@@ -18,7 +18,28 @@
|
|
18
18
|
|
19
19
|
module Kitchen
|
20
20
|
|
21
|
-
module Error
|
21
|
+
module Error
|
22
|
+
|
23
|
+
def self.formatted_trace(exception)
|
24
|
+
arr = formatted_exception(exception).dup
|
25
|
+
last = arr.pop
|
26
|
+
if exception.respond_to?(:original) && exception.original
|
27
|
+
arr += formatted_exception(exception.original, "Nested Exception")
|
28
|
+
last = arr.pop
|
29
|
+
end
|
30
|
+
arr += ["Backtrace".center(22, "-"), exception.backtrace, last].flatten
|
31
|
+
arr
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.formatted_exception(exception, title = "Exception")
|
35
|
+
[
|
36
|
+
title.center(22, "-"),
|
37
|
+
"Class: #{exception.class}",
|
38
|
+
"Message: #{exception.message}",
|
39
|
+
"".center(22, "-"),
|
40
|
+
]
|
41
|
+
end
|
42
|
+
end
|
22
43
|
|
23
44
|
# Base exception class from which all Kitchen exceptions derive. This class
|
24
45
|
# nests an exception when this class is re-raised from a rescue block.
|
@@ -49,4 +70,56 @@ module Kitchen
|
|
49
70
|
# Exception class for any exceptions raised when performing an instance
|
50
71
|
# action.
|
51
72
|
class ActionFailed < TransientFailure ; end
|
73
|
+
|
74
|
+
# Exception class capturing what caused an instance to die.
|
75
|
+
class InstanceFailure < TransientFailure ; end
|
76
|
+
|
77
|
+
def self.with_friendly_errors
|
78
|
+
yield
|
79
|
+
rescue Kitchen::InstanceFailure => e
|
80
|
+
Kitchen.mutex.synchronize do
|
81
|
+
handle_instance_failure(e)
|
82
|
+
end
|
83
|
+
exit 10
|
84
|
+
rescue Kitchen::Error => e
|
85
|
+
Kitchen.mutex.synchronize do
|
86
|
+
handle_error(e)
|
87
|
+
end
|
88
|
+
exit 20
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def self.file_log(level, lines)
|
94
|
+
Array(lines).each do |line|
|
95
|
+
if Kitchen.logger.debug?
|
96
|
+
Kitchen.logger.debug(line)
|
97
|
+
else
|
98
|
+
Kitchen.logger.logdev && Kitchen.logger.logdev.public_send(level, line)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.stderr_log(lines)
|
104
|
+
Array(lines).each do |line|
|
105
|
+
$stderr.puts(Color.colorize(">>>>>> #{line}", :red))
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.debug_log(lines)
|
110
|
+
Array(lines).each { |line| Kitchen.logger.debug(line) }
|
111
|
+
end
|
112
|
+
|
113
|
+
def self.handle_instance_failure(e)
|
114
|
+
stderr_log(e.message.split(/\s{2,}/))
|
115
|
+
stderr_log(Error.formatted_exception(e.original))
|
116
|
+
file_log(:error, e.message.split(/\s{2,}/).first)
|
117
|
+
debug_log(Error.formatted_trace(e))
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.handle_error(e)
|
121
|
+
stderr_log(Error.formatted_exception(e))
|
122
|
+
stderr_log("Please see .kitchen/logs/kitchen.log for more details\n")
|
123
|
+
file_log(:error, Error.formatted_trace(e))
|
124
|
+
end
|
52
125
|
end
|
data/lib/kitchen/instance.rb
CHANGED
@@ -260,10 +260,14 @@ module Kitchen
|
|
260
260
|
end
|
261
261
|
state[:last_action] = what.to_s
|
262
262
|
elapsed
|
263
|
-
rescue ActionFailed
|
264
|
-
|
263
|
+
rescue ActionFailed => e
|
264
|
+
log_failure(what, e)
|
265
|
+
raise InstanceFailure, failure_message(what) +
|
266
|
+
" Please see .kitchen/logs/#{self.name}.log for more details", caller
|
265
267
|
rescue Exception => e
|
266
|
-
|
268
|
+
log_failure(what, e)
|
269
|
+
raise ActionFailed,
|
270
|
+
"Failed to complete ##{what} action: [#{e.message}]", caller
|
267
271
|
ensure
|
268
272
|
state_file.write(state)
|
269
273
|
end
|
@@ -289,6 +293,17 @@ module Kitchen
|
|
289
293
|
super
|
290
294
|
end
|
291
295
|
|
296
|
+
def log_failure(what, e)
|
297
|
+
return if logger.logdev.nil?
|
298
|
+
|
299
|
+
logger.logdev.error(failure_message(what))
|
300
|
+
Error.formatted_trace(e).each { |line| logger.logdev.error(line) }
|
301
|
+
end
|
302
|
+
|
303
|
+
def failure_message(what)
|
304
|
+
"#{what.capitalize} failed on instance #{self.to_str}."
|
305
|
+
end
|
306
|
+
|
292
307
|
# The simplest finite state machine pseudo-implementation needed to manage
|
293
308
|
# an Instance.
|
294
309
|
#
|
data/lib/kitchen/loader/yaml.rb
CHANGED
@@ -69,6 +69,9 @@ module Kitchen
|
|
69
69
|
|
70
70
|
def combined_hash
|
71
71
|
@process_local ? yaml.rmerge(local_yaml) : yaml
|
72
|
+
rescue NoMethodError
|
73
|
+
raise UserError, "Error merging #{File.basename(config_file)} and" +
|
74
|
+
"#{File.basename(local_config_file)}"
|
72
75
|
end
|
73
76
|
|
74
77
|
def yaml
|
@@ -97,8 +100,8 @@ module Kitchen
|
|
97
100
|
return Hash.new if string.nil? || string.empty?
|
98
101
|
|
99
102
|
::YAML.safe_load(string)
|
100
|
-
rescue SyntaxError, Psych::SyntaxError
|
101
|
-
raise UserError, "Error parsing #{file_name}
|
103
|
+
rescue SyntaxError, Psych::SyntaxError
|
104
|
+
raise UserError, "Error parsing #{file_name}"
|
102
105
|
end
|
103
106
|
end
|
104
107
|
end
|
data/lib/kitchen/logger.rb
CHANGED
data/lib/kitchen/version.rb
CHANGED
data/lib/kitchen.rb
CHANGED
@@ -72,6 +72,11 @@ module Kitchen
|
|
72
72
|
Logger.new(:stdout => STDOUT, :logdev => logfile, :level => env_log)
|
73
73
|
end
|
74
74
|
|
75
|
+
def celluloid_file_logger
|
76
|
+
logfile = File.expand_path(File.join(".kitchen", "logs", "celluloid.log"))
|
77
|
+
Logger.new(:logdev => logfile, :level => env_log, :progname => "Celluloid")
|
78
|
+
end
|
79
|
+
|
75
80
|
private
|
76
81
|
|
77
82
|
def env_log
|
@@ -86,7 +91,7 @@ end
|
|
86
91
|
|
87
92
|
# Initialize the base logger and use that for Celluloid's logger
|
88
93
|
Kitchen.logger = Kitchen.default_logger
|
89
|
-
Celluloid.logger = Kitchen.
|
94
|
+
Celluloid.logger = Kitchen.celluloid_file_logger
|
90
95
|
|
91
96
|
# Setup a collection of instance crash exceptions for error reporting
|
92
97
|
Kitchen.crashes = []
|
@@ -152,6 +152,13 @@ describe Kitchen::Loader::YAML do
|
|
152
152
|
proc { loader.read }.must_raise Kitchen::UserError
|
153
153
|
end
|
154
154
|
|
155
|
+
it "raises a UserError if kitchen.yml cannot be parsed" do
|
156
|
+
FileUtils.mkdir_p "/tmp"
|
157
|
+
File.open("/tmp/.kitchen.yml", "wb") { |f| f.write 'uhoh' }
|
158
|
+
|
159
|
+
proc { loader.read }.must_raise Kitchen::UserError
|
160
|
+
end
|
161
|
+
|
155
162
|
it "raises a UserError if kitchen.local.yml cannot be parsed" do
|
156
163
|
FileUtils.mkdir_p "/tmp"
|
157
164
|
File.open("/tmp/.kitchen.local.yml", "wb") { |f| f.write '&*%^*' }
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: test-kitchen
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.0.alpha.
|
4
|
+
version: 1.0.0.alpha.5
|
5
5
|
prerelease: 6
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2013-04-
|
12
|
+
date: 2013-04-23 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: celluloid
|