jamie 0.1.0.alpha21 → 0.1.0.beta1
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/.travis.yml +8 -0
- data/Rakefile +32 -24
- data/jamie.gemspec +1 -0
- data/lib/jamie.rb +132 -18
- data/lib/jamie/cli.rb +29 -8
- data/lib/jamie/version.rb +1 -1
- data/spec/jamie_spec.rb +6 -0
- metadata +18 -2
data/.travis.yml
CHANGED
data/Rakefile
CHANGED
@@ -1,24 +1,38 @@
|
|
1
1
|
require 'bundler/gem_tasks'
|
2
|
-
require 'cane/rake_task'
|
3
2
|
require 'rake/testtask'
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
Jamie::RakeTasks#define
|
10
|
-
Jamie::ThorTasks#define
|
11
|
-
Jamie::CLI#pry_prompts
|
12
|
-
)
|
13
|
-
cane.style_exclude = %w(
|
14
|
-
lib/vendor/hash_recursive_merge.rb
|
15
|
-
)
|
16
|
-
cane.doc_exclude = %w(
|
17
|
-
lib/vendor/hash_recursive_merge.rb
|
18
|
-
)
|
3
|
+
|
4
|
+
Rake::TestTask.new do |t|
|
5
|
+
t.libs.push "lib"
|
6
|
+
t.test_files = FileList['spec/**/*_spec.rb']
|
7
|
+
t.verbose = true
|
19
8
|
end
|
20
9
|
|
21
|
-
|
10
|
+
task :default => [ :test ]
|
11
|
+
|
12
|
+
unless RUBY_ENGINE == 'jruby'
|
13
|
+
require 'cane/rake_task'
|
14
|
+
require 'tailor/rake_task'
|
15
|
+
|
16
|
+
desc "Run cane to check quality metrics"
|
17
|
+
Cane::RakeTask.new do |cane|
|
18
|
+
cane.abc_exclude = %w(
|
19
|
+
Jamie::RakeTasks#define
|
20
|
+
Jamie::ThorTasks#define
|
21
|
+
Jamie::CLI#pry_prompts
|
22
|
+
Jamie::Instance#synchronize_or_call
|
23
|
+
)
|
24
|
+
cane.style_exclude = %w(
|
25
|
+
lib/vendor/hash_recursive_merge.rb
|
26
|
+
)
|
27
|
+
cane.doc_exclude = %w(
|
28
|
+
lib/vendor/hash_recursive_merge.rb
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
Tailor::RakeTask.new
|
33
|
+
|
34
|
+
Rake::Task[:default].enhance [ :cane, :tailor ]
|
35
|
+
end
|
22
36
|
|
23
37
|
desc "Display LOC stats"
|
24
38
|
task :stats do
|
@@ -28,10 +42,4 @@ task :stats do
|
|
28
42
|
sh "countloc -r spec"
|
29
43
|
end
|
30
44
|
|
31
|
-
Rake::
|
32
|
-
t.libs.push "lib"
|
33
|
-
t.test_files = FileList['spec/**/*_spec.rb']
|
34
|
-
t.verbose = true
|
35
|
-
end
|
36
|
-
|
37
|
-
task :default => [ :test, :cane, :tailor, :stats ]
|
45
|
+
Rake::Task[:default].enhance [ :stats ]
|
data/jamie.gemspec
CHANGED
data/lib/jamie.rb
CHANGED
@@ -18,6 +18,7 @@
|
|
18
18
|
|
19
19
|
require 'base64'
|
20
20
|
require 'benchmark'
|
21
|
+
require 'celluloid'
|
21
22
|
require 'delegate'
|
22
23
|
require 'digest'
|
23
24
|
require 'erb'
|
@@ -29,6 +30,7 @@ require 'net/https'
|
|
29
30
|
require 'net/scp'
|
30
31
|
require 'net/ssh'
|
31
32
|
require 'pathname'
|
33
|
+
require 'thread'
|
32
34
|
require 'socket'
|
33
35
|
require 'stringio'
|
34
36
|
require 'vendor/hash_recursive_merge'
|
@@ -41,6 +43,8 @@ module Jamie
|
|
41
43
|
class << self
|
42
44
|
|
43
45
|
attr_accessor :logger
|
46
|
+
attr_accessor :crashes
|
47
|
+
attr_accessor :mutex
|
44
48
|
|
45
49
|
# Returns the root path of the Jamie gem source code.
|
46
50
|
#
|
@@ -49,10 +53,15 @@ module Jamie
|
|
49
53
|
@source_root ||= Pathname.new(File.expand_path('../../', __FILE__))
|
50
54
|
end
|
51
55
|
|
56
|
+
def crashes?
|
57
|
+
! crashes.empty?
|
58
|
+
end
|
59
|
+
|
52
60
|
def default_logger
|
53
61
|
env_log = ENV['JAMIE_LOG'] && ENV['JAMIE_LOG'].downcase.to_sym
|
62
|
+
env_log = Util.to_logger_level(env_log) unless env_log.nil?
|
54
63
|
|
55
|
-
Logger.new(:
|
64
|
+
Logger.new(:stdout => STDOUT, :level => env_log)
|
56
65
|
end
|
57
66
|
end
|
58
67
|
|
@@ -101,9 +110,7 @@ module Jamie
|
|
101
110
|
# @return [Array<Instance>] all instances, resulting from all platform and
|
102
111
|
# suite combinations
|
103
112
|
def instances
|
104
|
-
|
105
|
-
platforms.map { |platform| new_instance(suite, platform) }
|
106
|
-
}.flatten)
|
113
|
+
instances_array(load_instances)
|
107
114
|
end
|
108
115
|
|
109
116
|
# @return [String] path to the Jamie YAML file
|
@@ -162,6 +169,24 @@ module Jamie
|
|
162
169
|
|
163
170
|
private
|
164
171
|
|
172
|
+
def load_instances
|
173
|
+
return @instance_count if @instance_count && @instance_count > 0
|
174
|
+
|
175
|
+
results = []
|
176
|
+
suites.product(platforms).each_with_index do |arr, index|
|
177
|
+
results << new_instance(arr[0], arr[1], index)
|
178
|
+
end
|
179
|
+
@instance_count = results.size
|
180
|
+
end
|
181
|
+
|
182
|
+
def instances_array(instance_count)
|
183
|
+
results = []
|
184
|
+
instance_count.times do |index|
|
185
|
+
results << Celluloid::Actor["instance_#{index}".to_sym]
|
186
|
+
end
|
187
|
+
Collection.new(results)
|
188
|
+
end
|
189
|
+
|
165
190
|
def new_suite(hash)
|
166
191
|
path_hash = {
|
167
192
|
:data_bags_path => calculate_path("data_bags", hash[:name]),
|
@@ -182,19 +207,24 @@ module Jamie
|
|
182
207
|
Driver.for_plugin(hash[:driver_plugin], hash[:driver_config])
|
183
208
|
end
|
184
209
|
|
185
|
-
def new_instance(suite, platform)
|
186
|
-
log_root = File.expand_path(File.join(jamie_root, ".jamie", "logs"))
|
210
|
+
def new_instance(suite, platform, index)
|
187
211
|
platform_hash = platform_driver_hash(platform.name)
|
188
212
|
driver = new_driver(merge_driver_hash(platform_hash))
|
189
213
|
FileUtils.mkdir_p(log_root)
|
190
214
|
|
191
|
-
Instance.
|
215
|
+
supervisor = Instance.supervise_as(
|
216
|
+
"instance_#{index}".to_sym,
|
192
217
|
:suite => suite,
|
193
218
|
:platform => platform,
|
194
219
|
:driver => driver,
|
195
220
|
:jr => Jr.new(suite.name),
|
196
|
-
:logger => new_instance_logger(
|
221
|
+
:logger => new_instance_logger(index)
|
197
222
|
)
|
223
|
+
supervisor.actors.first
|
224
|
+
end
|
225
|
+
|
226
|
+
def log_root
|
227
|
+
File.expand_path(File.join(jamie_root, ".jamie", "logs"))
|
198
228
|
end
|
199
229
|
|
200
230
|
def platform_driver_hash(platform_name)
|
@@ -203,13 +233,14 @@ module Jamie
|
|
203
233
|
h.select { |key, value| [ :driver_plugin, :driver_config ].include?(key) }
|
204
234
|
end
|
205
235
|
|
206
|
-
def new_instance_logger(
|
236
|
+
def new_instance_logger(index)
|
207
237
|
level = Util.to_logger_level(self.log_level)
|
238
|
+
color = Color::COLORS[index % Color::COLORS.size].to_sym
|
208
239
|
|
209
240
|
lambda do |name|
|
210
241
|
logfile = File.join(log_root, "#{name}.log")
|
211
242
|
|
212
|
-
Logger.new(:stdout => STDOUT, :logdev => logfile,
|
243
|
+
Logger.new(:stdout => STDOUT, :color => color, :logdev => logfile,
|
213
244
|
:level => level, :progname => name)
|
214
245
|
end
|
215
246
|
end
|
@@ -276,6 +307,31 @@ module Jamie
|
|
276
307
|
# Default log level verbosity
|
277
308
|
DEFAULT_LOG_LEVEL = :info
|
278
309
|
|
310
|
+
module Color
|
311
|
+
ANSI = {
|
312
|
+
:reset => 0, :black => 30, :red => 31, :green => 32, :yellow => 33,
|
313
|
+
:blue => 34, :magenta => 35, :cyan => 36, :white => 37,
|
314
|
+
:bright_black => 30, :bright_red => 31, :bright_green => 32,
|
315
|
+
:bright_yellow => 33, :bright_blue => 34, :bright_magenta => 35,
|
316
|
+
:bright_cyan => 36, :bright_white => 37
|
317
|
+
}.freeze
|
318
|
+
|
319
|
+
COLORS = %w(
|
320
|
+
cyan yellow green magenta red blue bright_cyan bright_yellow
|
321
|
+
bright_green bright_magenta bright_red, bright_blue
|
322
|
+
).freeze
|
323
|
+
|
324
|
+
def self.escape(name)
|
325
|
+
return "" if name.nil?
|
326
|
+
return "" unless ansi = ANSI[name]
|
327
|
+
"\e[#{ansi}m"
|
328
|
+
end
|
329
|
+
|
330
|
+
def self.colorize(str, name)
|
331
|
+
"#{escape(name)}#{str}#{escape(:reset)}"
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
279
335
|
# Logging implementation for Jamie. By default the console/stdout output will
|
280
336
|
# be displayed differently than the file log output. Therefor, this class
|
281
337
|
# wraps multiple loggers that conform to the stdlib `Logger` class behavior.
|
@@ -286,10 +342,12 @@ module Jamie
|
|
286
342
|
include ::Logger::Severity
|
287
343
|
|
288
344
|
def initialize(options = {})
|
345
|
+
color = options[:color] || :bright_white
|
346
|
+
|
289
347
|
@loggers = []
|
290
348
|
@loggers << logdev_logger(options[:logdev]) if options[:logdev]
|
291
|
-
@loggers << stdout_logger(options[:stdout]) if options[:stdout]
|
292
|
-
@loggers << stdout_logger(STDOUT) if @loggers.empty?
|
349
|
+
@loggers << stdout_logger(options[:stdout], color) if options[:stdout]
|
350
|
+
@loggers << stdout_logger(STDOUT, color) if @loggers.empty?
|
293
351
|
|
294
352
|
self.progname = options[:progname] || "Jamie"
|
295
353
|
self.level = options[:level] || default_log_level
|
@@ -318,10 +376,10 @@ module Jamie
|
|
318
376
|
Util.to_logger_level(Jamie::DEFAULT_LOG_LEVEL)
|
319
377
|
end
|
320
378
|
|
321
|
-
def stdout_logger(stdout)
|
379
|
+
def stdout_logger(stdout, color)
|
322
380
|
logger = StdoutLogger.new(stdout)
|
323
381
|
logger.formatter = proc do |severity, datetime, progname, msg|
|
324
|
-
"#{msg}\n"
|
382
|
+
Color.colorize("#{msg}\n", color)
|
325
383
|
end
|
326
384
|
logger
|
327
385
|
end
|
@@ -500,8 +558,13 @@ module Jamie
|
|
500
558
|
# @author Fletcher Nichol <fnichol@nichol.ca>
|
501
559
|
class Instance
|
502
560
|
|
561
|
+
include Celluloid
|
503
562
|
include Logging
|
504
563
|
|
564
|
+
class << self
|
565
|
+
attr_accessor :mutexes
|
566
|
+
end
|
567
|
+
|
505
568
|
# @return [Suite] the test suite configuration
|
506
569
|
attr_reader :suite
|
507
570
|
|
@@ -538,6 +601,7 @@ module Jamie
|
|
538
601
|
@logger = logger.is_a?(Proc) ? logger.call(name) : logger
|
539
602
|
|
540
603
|
@driver.instance = self
|
604
|
+
setup_driver_mutex
|
541
605
|
end
|
542
606
|
|
543
607
|
# @return [String] name of this instance
|
@@ -643,7 +707,7 @@ module Jamie
|
|
643
707
|
destroy if destroy_mode == :passing
|
644
708
|
end
|
645
709
|
info "Finished testing #{to_str} (#{elapsed.real} seconds)."
|
646
|
-
|
710
|
+
Actor.current
|
647
711
|
ensure
|
648
712
|
destroy if destroy_mode == :always
|
649
713
|
end
|
@@ -670,6 +734,15 @@ module Jamie
|
|
670
734
|
end
|
671
735
|
end
|
672
736
|
|
737
|
+
def setup_driver_mutex
|
738
|
+
if driver.class.serial_actions
|
739
|
+
Jamie.mutex.synchronize do
|
740
|
+
self.class.mutexes ||= Hash.new
|
741
|
+
self.class.mutexes[driver.class] = Mutex.new
|
742
|
+
end
|
743
|
+
end
|
744
|
+
end
|
745
|
+
|
673
746
|
def transition_to(desired)
|
674
747
|
result = nil
|
675
748
|
FSM.actions(last_action, desired).each do |transition|
|
@@ -704,13 +777,13 @@ module Jamie
|
|
704
777
|
info("Finished #{output_verb.downcase} #{to_str}" +
|
705
778
|
" (#{elapsed.real} seconds).")
|
706
779
|
yield if block_given?
|
707
|
-
|
780
|
+
Actor.current
|
708
781
|
end
|
709
782
|
|
710
|
-
def action(what)
|
783
|
+
def action(what, &block)
|
711
784
|
state = load_state
|
712
785
|
elapsed = Benchmark.measure do
|
713
|
-
|
786
|
+
synchronize_or_call(what, state, &block)
|
714
787
|
end
|
715
788
|
state[:last_action] = what.to_s
|
716
789
|
elapsed
|
@@ -718,6 +791,18 @@ module Jamie
|
|
718
791
|
dump_state(state)
|
719
792
|
end
|
720
793
|
|
794
|
+
def synchronize_or_call(what, state, &block)
|
795
|
+
if Array(driver.class.serial_actions).include?(what)
|
796
|
+
debug("#{to_str} is synchronizing on #{driver.class}##{what}")
|
797
|
+
self.class.mutexes[driver.class].synchronize do
|
798
|
+
debug("#{to_str} is messaging #{driver.class}##{what}")
|
799
|
+
block.call(state)
|
800
|
+
end
|
801
|
+
else
|
802
|
+
block.call(state)
|
803
|
+
end
|
804
|
+
end
|
805
|
+
|
721
806
|
def load_state
|
722
807
|
if File.exists?(statefile)
|
723
808
|
Util.symbolized_hash(YAML.load_file(statefile))
|
@@ -1037,6 +1122,10 @@ module Jamie
|
|
1037
1122
|
|
1038
1123
|
attr_writer :instance
|
1039
1124
|
|
1125
|
+
class << self
|
1126
|
+
attr_reader :serial_actions
|
1127
|
+
end
|
1128
|
+
|
1040
1129
|
def initialize(config = {})
|
1041
1130
|
@config = config
|
1042
1131
|
self.class.defaults.each do |attr, value|
|
@@ -1096,6 +1185,9 @@ module Jamie
|
|
1096
1185
|
|
1097
1186
|
attr_reader :config, :instance
|
1098
1187
|
|
1188
|
+
ACTION_METHODS = %w{create converge setup verify destroy}.
|
1189
|
+
map(&:to_sym).freeze
|
1190
|
+
|
1099
1191
|
def logger
|
1100
1192
|
instance.logger
|
1101
1193
|
end
|
@@ -1122,6 +1214,17 @@ module Jamie
|
|
1122
1214
|
def self.default_config(attr, value)
|
1123
1215
|
defaults[attr] = value
|
1124
1216
|
end
|
1217
|
+
|
1218
|
+
def self.no_parallel_for(*methods)
|
1219
|
+
Array(methods).each do |meth|
|
1220
|
+
if ! ACTION_METHODS.include?(meth)
|
1221
|
+
raise ArgumentError, "##{meth} is not a whitelisted method."
|
1222
|
+
end
|
1223
|
+
end
|
1224
|
+
|
1225
|
+
@serial_actions ||= []
|
1226
|
+
@serial_actions += methods
|
1227
|
+
end
|
1125
1228
|
end
|
1126
1229
|
|
1127
1230
|
# Base class for a driver that uses SSH to communication with an instance.
|
@@ -1435,4 +1538,15 @@ module Jamie
|
|
1435
1538
|
end
|
1436
1539
|
end
|
1437
1540
|
|
1541
|
+
# Initialize the base logger and use that for Celluloid's logger
|
1438
1542
|
Jamie.logger = Jamie.default_logger
|
1543
|
+
Celluloid.logger = Jamie.logger
|
1544
|
+
|
1545
|
+
# Setup a collection of instance crash exceptions for error reporting
|
1546
|
+
Jamie.crashes = []
|
1547
|
+
Celluloid.exception_handler do |exception|
|
1548
|
+
Jamie.logger.debug("An instance crashed because of #{exception.inspect}")
|
1549
|
+
Jamie.crashes << exception
|
1550
|
+
end
|
1551
|
+
|
1552
|
+
Jamie.mutex = Mutex.new
|
data/lib/jamie/cli.rb
CHANGED
@@ -35,6 +35,7 @@ module Jamie
|
|
35
35
|
# Constructs a new instance.
|
36
36
|
def initialize(*args)
|
37
37
|
super
|
38
|
+
$stdout.sync = true
|
38
39
|
@config = Jamie::Config.new(ENV['JAMIE_YAML'])
|
39
40
|
end
|
40
41
|
|
@@ -46,9 +47,11 @@ module Jamie
|
|
46
47
|
|
47
48
|
[:create, :converge, :setup, :verify, :destroy].each do |action|
|
48
49
|
desc(
|
49
|
-
"#{action} [(all|<REGEX>)]",
|
50
|
+
"#{action} [(all|<REGEX>)] [opts]",
|
50
51
|
"#{action.capitalize} one or more instances"
|
51
52
|
)
|
53
|
+
method_option :parallel, :aliases => "-p", :type => :boolean,
|
54
|
+
:desc => "Perform action against all matching instances in parallel"
|
52
55
|
define_method(action) { |*args| exec_action(action) }
|
53
56
|
end
|
54
57
|
|
@@ -63,17 +66,26 @@ module Jamie
|
|
63
66
|
* always: instances will always be destroyed afterwards.\n
|
64
67
|
* never: instances will never be destroyed afterwards.
|
65
68
|
DESC
|
69
|
+
method_option :parallel, :aliases => "-p", :type => :boolean,
|
70
|
+
:desc => "Perform action against all matching instances in parallel"
|
66
71
|
method_option :destroy, :aliases => "-d", :default => "passing",
|
67
72
|
:desc => "Destroy strategy to use after testing (passing, always, never)."
|
68
73
|
def test(*args)
|
74
|
+
if ! %w{passing always never}.include?(options[:destroy])
|
75
|
+
raise ArgumentError, "Destroy mode must be passing, always, or never."
|
76
|
+
end
|
77
|
+
|
69
78
|
banner "Starting Jamie"
|
70
79
|
elapsed = Benchmark.measure do
|
71
|
-
destroy_mode = options[:destroy]
|
72
|
-
|
73
|
-
|
80
|
+
destroy_mode = options[:destroy].to_sym
|
81
|
+
@task = :test
|
82
|
+
results = parse_subcommand(args.first)
|
83
|
+
|
84
|
+
if options[:parallel]
|
85
|
+
run_parallel(results, destroy_mode)
|
86
|
+
else
|
87
|
+
run_serial(results, destroy_mode)
|
74
88
|
end
|
75
|
-
result = parse_subcommand(args.first)
|
76
|
-
Array(result).each { |instance| instance.test(destroy_mode.to_sym) }
|
77
89
|
end
|
78
90
|
banner "Jamie is finished. (#{elapsed.real} seconds)"
|
79
91
|
end
|
@@ -132,12 +144,21 @@ module Jamie
|
|
132
144
|
banner "Starting Jamie"
|
133
145
|
elapsed = Benchmark.measure do
|
134
146
|
@task = action
|
135
|
-
|
136
|
-
|
147
|
+
results = parse_subcommand(args.first)
|
148
|
+
options[:parallel] ? run_parallel(results) : run_serial(results)
|
137
149
|
end
|
138
150
|
banner "Jamie is finished. (#{elapsed.real} seconds)"
|
139
151
|
end
|
140
152
|
|
153
|
+
def run_serial(instances, *args)
|
154
|
+
Array(instances).map { |i| i.public_send(task, *args) }
|
155
|
+
end
|
156
|
+
|
157
|
+
def run_parallel(instances, *args)
|
158
|
+
futures = Array(instances).map { |i| i.future.public_send(task) }
|
159
|
+
futures.map { |i| i.value }
|
160
|
+
end
|
161
|
+
|
141
162
|
def parse_subcommand(arg = nil)
|
142
163
|
arg == "all" ? get_all_instances : get_filtered_instances(arg)
|
143
164
|
end
|
data/lib/jamie/version.rb
CHANGED
data/spec/jamie_spec.rb
CHANGED
@@ -28,8 +28,10 @@ end
|
|
28
28
|
SimpleCov.start 'gem'
|
29
29
|
|
30
30
|
require 'fakefs/spec_helpers'
|
31
|
+
require 'logger'
|
31
32
|
require 'minitest/autorun'
|
32
33
|
require 'ostruct'
|
34
|
+
require 'stringio'
|
33
35
|
|
34
36
|
require 'jamie'
|
35
37
|
require 'jamie/driver/dummy'
|
@@ -391,6 +393,10 @@ describe Jamie::Instance do
|
|
391
393
|
|
392
394
|
let(:instance) { Jamie::Instance.new(opts) }
|
393
395
|
|
396
|
+
before do
|
397
|
+
Celluloid.logger = Logger.new(StringIO.new)
|
398
|
+
end
|
399
|
+
|
394
400
|
it "raises an ArgumentError if suite is missing" do
|
395
401
|
opts.delete(:suite)
|
396
402
|
proc { Jamie::Instance.new(opts) }.must_raise ArgumentError
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jamie
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.0.
|
4
|
+
version: 0.1.0.beta1
|
5
5
|
prerelease: 6
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,8 +9,24 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2013-01-
|
12
|
+
date: 2013-01-12 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: celluloid
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
14
30
|
- !ruby/object:Gem::Dependency
|
15
31
|
name: thor
|
16
32
|
requirement: !ruby/object:Gem::Requirement
|