test-kitchen 0.7.0 → 1.0.0.alpha.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. data/.gitignore +20 -0
  2. data/.travis.yml +11 -0
  3. data/.yardopts +3 -0
  4. data/Gemfile +13 -0
  5. data/Guardfile +11 -0
  6. data/LICENSE +15 -0
  7. data/README.md +131 -0
  8. data/Rakefile +69 -0
  9. data/bin/kitchen +9 -4
  10. data/features/cli.feature +17 -0
  11. data/features/cli_init.feature +156 -0
  12. data/features/support/env.rb +14 -0
  13. data/lib/kitchen/busser.rb +166 -0
  14. data/lib/kitchen/chef_data_uploader.rb +156 -0
  15. data/lib/kitchen/cli.rb +540 -0
  16. data/lib/kitchen/collection.rb +55 -0
  17. data/lib/kitchen/color.rb +46 -0
  18. data/lib/kitchen/config.rb +223 -0
  19. data/lib/kitchen/driver/base.rb +180 -0
  20. data/lib/kitchen/driver/dummy.rb +81 -0
  21. data/lib/kitchen/driver/ssh_base.rb +192 -0
  22. data/lib/kitchen/driver.rb +42 -0
  23. data/lib/kitchen/errors.rb +52 -0
  24. data/lib/kitchen/instance.rb +327 -0
  25. data/lib/kitchen/instance_actor.rb +42 -0
  26. data/lib/kitchen/loader/yaml.rb +105 -0
  27. data/lib/kitchen/logger.rb +145 -0
  28. data/{cookbooks/test-kitchen/libraries/helpers.rb → lib/kitchen/logging.rb} +13 -9
  29. data/lib/kitchen/manager.rb +45 -0
  30. data/lib/kitchen/metadata_chopper.rb +52 -0
  31. data/lib/kitchen/platform.rb +61 -0
  32. data/lib/kitchen/rake_tasks.rb +59 -0
  33. data/lib/kitchen/shell_out.rb +65 -0
  34. data/lib/kitchen/state_file.rb +88 -0
  35. data/lib/kitchen/suite.rb +76 -0
  36. data/lib/kitchen/thor_tasks.rb +62 -0
  37. data/lib/kitchen/util.rb +79 -0
  38. data/{cookbooks/test-kitchen/recipes/erlang.rb → lib/kitchen/version.rb} +9 -6
  39. data/lib/kitchen.rb +98 -0
  40. data/lib/vendor/hash_recursive_merge.rb +74 -0
  41. data/spec/kitchen/collection_spec.rb +80 -0
  42. data/spec/kitchen/color_spec.rb +54 -0
  43. data/spec/kitchen/config_spec.rb +201 -0
  44. data/spec/kitchen/driver/dummy_spec.rb +191 -0
  45. data/spec/kitchen/instance_spec.rb +162 -0
  46. data/spec/kitchen/loader/yaml_spec.rb +243 -0
  47. data/spec/kitchen/platform_spec.rb +48 -0
  48. data/spec/kitchen/state_file_spec.rb +122 -0
  49. data/spec/kitchen/suite_spec.rb +64 -0
  50. data/spec/spec_helper.rb +47 -0
  51. data/templates/plugin/driver.rb.erb +23 -0
  52. data/templates/plugin/license_apachev2.erb +15 -0
  53. data/templates/plugin/license_gplv2.erb +18 -0
  54. data/templates/plugin/license_gplv3.erb +16 -0
  55. data/templates/plugin/license_mit.erb +22 -0
  56. data/templates/plugin/license_reserved.erb +5 -0
  57. data/templates/plugin/version.rb.erb +12 -0
  58. data/test-kitchen.gemspec +44 -0
  59. metadata +290 -82
  60. data/config/Cheffile +0 -47
  61. data/config/Kitchenfile +0 -39
  62. data/config/Vagrantfile +0 -114
  63. data/cookbooks/test-kitchen/attributes/default.rb +0 -25
  64. data/cookbooks/test-kitchen/metadata.rb +0 -27
  65. data/cookbooks/test-kitchen/recipes/chef.rb +0 -19
  66. data/cookbooks/test-kitchen/recipes/compat.rb +0 -39
  67. data/cookbooks/test-kitchen/recipes/default.rb +0 -51
  68. data/cookbooks/test-kitchen/recipes/ruby.rb +0 -29
  69. data/lib/test-kitchen/cli/destroy.rb +0 -36
  70. data/lib/test-kitchen/cli/init.rb +0 -37
  71. data/lib/test-kitchen/cli/platform_list.rb +0 -37
  72. data/lib/test-kitchen/cli/project_info.rb +0 -44
  73. data/lib/test-kitchen/cli/ssh.rb +0 -36
  74. data/lib/test-kitchen/cli/status.rb +0 -36
  75. data/lib/test-kitchen/cli/test.rb +0 -68
  76. data/lib/test-kitchen/cli.rb +0 -282
  77. data/lib/test-kitchen/dsl.rb +0 -63
  78. data/lib/test-kitchen/environment.rb +0 -166
  79. data/lib/test-kitchen/platform.rb +0 -79
  80. data/lib/test-kitchen/project/base.rb +0 -159
  81. data/lib/test-kitchen/project/cookbook.rb +0 -97
  82. data/lib/test-kitchen/project/cookbook_copy.rb +0 -58
  83. data/lib/test-kitchen/project/ruby.rb +0 -37
  84. data/lib/test-kitchen/project/supported_platforms.rb +0 -75
  85. data/lib/test-kitchen/project.rb +0 -23
  86. data/lib/test-kitchen/runner/base.rb +0 -154
  87. data/lib/test-kitchen/runner/openstack/dsl.rb +0 -39
  88. data/lib/test-kitchen/runner/openstack/environment.rb +0 -141
  89. data/lib/test-kitchen/runner/openstack.rb +0 -147
  90. data/lib/test-kitchen/runner/vagrant.rb +0 -95
  91. data/lib/test-kitchen/runner.rb +0 -21
  92. data/lib/test-kitchen/scaffold.rb +0 -88
  93. data/lib/test-kitchen/ui.rb +0 -73
  94. data/lib/test-kitchen/version.rb +0 -21
  95. data/lib/test-kitchen.rb +0 -34
@@ -0,0 +1,156 @@
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
4
+ #
5
+ # Copyright (C) 2012, Fletcher Nichol
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+
19
+ require 'fileutils'
20
+ require 'json'
21
+ require 'net/scp'
22
+ require 'stringio'
23
+
24
+ module Kitchen
25
+
26
+ # Uploads Chef asset files such as dna.json, data bags, and cookbooks to an
27
+ # instance over SSH.
28
+ #
29
+ # @author Fletcher Nichol <fnichol@nichol.ca>
30
+ class ChefDataUploader
31
+
32
+ include ShellOut
33
+ include Logging
34
+
35
+ def initialize(instance, ssh_args, kitchen_root, chef_home)
36
+ @instance = instance
37
+ @ssh_args = ssh_args
38
+ @kitchen_root = kitchen_root
39
+ @chef_home = chef_home
40
+ end
41
+
42
+ def upload
43
+ Net::SCP.start(*ssh_args) do |scp|
44
+ upload_json scp
45
+ upload_solo_rb scp
46
+ upload_cookbooks scp
47
+ upload_data_bags scp if instance.suite.data_bags_path
48
+ upload_roles scp if instance.suite.roles_path
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :instance, :ssh_args, :kitchen_root, :chef_home
55
+
56
+ def logger
57
+ instance.logger
58
+ end
59
+
60
+ def upload_json(scp)
61
+ json_file = StringIO.new(instance.dna.to_json)
62
+ scp.upload!(json_file, "#{chef_home}/dna.json")
63
+ end
64
+
65
+ def upload_solo_rb(scp)
66
+ solo_rb_file = StringIO.new(solo_rb_contents)
67
+ scp.upload!(solo_rb_file, "#{chef_home}/solo.rb")
68
+ end
69
+
70
+ def upload_cookbooks(scp)
71
+ cookbooks_dir = local_cookbooks
72
+ upload_path(scp, cookbooks_dir, "cookbooks")
73
+ ensure
74
+ FileUtils.rmtree(cookbooks_dir)
75
+ end
76
+
77
+ def upload_data_bags(scp)
78
+ upload_path(scp, instance.suite.data_bags_path)
79
+ end
80
+
81
+ def upload_roles(scp)
82
+ upload_path(scp, instance.suite.roles_path)
83
+ end
84
+
85
+ def upload_path(scp, path, dir = File.basename(path))
86
+ dest = "#{chef_home}/#{dir}"
87
+
88
+ scp.upload!(path, dest, :recursive => true) do |ch, name, sent, total|
89
+ if sent == total
90
+ info("Uploaded #{name.sub(%r{^#{path}/}, '')} (#{total} bytes)")
91
+ end
92
+ end
93
+ end
94
+
95
+ def solo_rb_contents
96
+ solo = []
97
+ solo << %{node_name "#{instance.name}"}
98
+ solo << %{file_cache_path "#{chef_home}/cache"}
99
+ solo << %{cookbook_path "#{chef_home}/cookbooks"}
100
+ solo << %{role_path "#{chef_home}/roles"}
101
+ if instance.suite.data_bags_path
102
+ solo << %{data_bag_path "#{chef_home}/data_bags"}
103
+ end
104
+ solo.join("\n")
105
+ end
106
+
107
+ def local_cookbooks
108
+ tmpdir = Dir.mktmpdir("#{instance.name}-cookbooks")
109
+ prepare_tmpdir(tmpdir)
110
+ tmpdir
111
+ end
112
+
113
+ def prepare_tmpdir(tmpdir)
114
+ if File.exists?(File.join(kitchen_root, "Berksfile"))
115
+ run_resolver("Berkshelf", "berks", tmpdir)
116
+ elsif File.exists?(File.join(kitchen_root, "Cheffile"))
117
+ run_resolver("Librarian", "librarian-chef", tmpdir)
118
+ elsif File.directory?(File.join(kitchen_root, "cookbooks"))
119
+ cp_cookbooks(tmpdir)
120
+ else
121
+ FileUtils.rmtree(tmpdir)
122
+ fatal("Berksfile, Cheffile or cookbooks/ must exist in #{kitchen_root}")
123
+ raise UserError, "Cookbooks could not be found"
124
+ end
125
+ end
126
+
127
+ def run_resolver(name, bin, tmpdir)
128
+ begin
129
+ run_command "if ! command -v #{bin} >/dev/null; then exit 1; fi"
130
+ rescue Kitchen::ShellOut::ShellCommandFailed
131
+ fatal("#{name} must be installed, add it to your Gemfile.")
132
+ raise UserError, "#{bin} command not found"
133
+ end
134
+
135
+ Kitchen.mutex.synchronize do
136
+ run_command "#{bin} install --path #{tmpdir}"
137
+ end
138
+ end
139
+
140
+ def cp_cookbooks(tmpdir)
141
+ FileUtils.cp_r(File.join(kitchen_root, "cookbooks", "."), tmpdir)
142
+ cp_this_cookbook(tmpdir) if File.exists?(File.expand_path('metadata.rb'))
143
+ end
144
+
145
+ def cp_this_cookbook(tmpdir)
146
+ metadata_rb = File.join(kitchen_root, "metadata.rb")
147
+ cb_name = MetadataChopper.extract(metadata_rb).first
148
+ cb_path = File.join(tmpdir, cb_name)
149
+ glob = Dir.glob("#{kitchen_root}/{metadata.rb,README.*," +
150
+ "attributes,files,libraries,providers,recipes,resources,templates}")
151
+
152
+ FileUtils.mkdir_p(cb_path)
153
+ FileUtils.cp_r(glob, cb_path)
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,540 @@
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
4
+ #
5
+ # Copyright (C) 2012, Fletcher Nichol
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+
19
+ require 'benchmark'
20
+ require 'erb'
21
+ require 'ostruct'
22
+ require 'thor'
23
+
24
+ require 'kitchen'
25
+
26
+ module Kitchen
27
+
28
+ # The command line runner for Kitchen.
29
+ #
30
+ # @author Fletcher Nichol <fnichol@nichol.ca>
31
+ class CLI < Thor
32
+
33
+ include Thor::Actions
34
+ include Logging
35
+
36
+ # Constructs a new instance.
37
+ def initialize(*args)
38
+ super
39
+ $stdout.sync = true
40
+ @config = Kitchen::Config.new(
41
+ :loader => Kitchen::Loader::YAML.new(ENV['KITCHEN_YAML']),
42
+ :log_level => ENV['KITCHEN_LOG'] && ENV['KITCHEN_LOG'].downcase.to_sym,
43
+ :supervised => false
44
+ )
45
+ Kitchen.logger = Kitchen.default_file_logger
46
+ end
47
+
48
+ desc "list [(all|<REGEX>)]", "List all instances"
49
+ method_option :bare, :aliases => "-b", :type => :boolean,
50
+ :desc => "List the name of each instance only, one per line"
51
+ def list(*args)
52
+ result = parse_subcommand(args.first)
53
+ if options[:bare]
54
+ say Array(result).map { |i| i.name }.join("\n")
55
+ else
56
+ table = [
57
+ [set_color("Instance", :green), set_color("Last Action", :green)]
58
+ ]
59
+ table += Array(result).map { |i| display_instance(i) }
60
+ print_table(table)
61
+ end
62
+ end
63
+
64
+ [:create, :converge, :setup, :verify, :destroy].each do |action|
65
+ desc(
66
+ "#{action} [(all|<REGEX>)] [opts]",
67
+ "#{action.capitalize} one or more instances"
68
+ )
69
+ method_option :parallel, :aliases => "-p", :type => :boolean,
70
+ :desc => "Perform action against all matching instances in parallel"
71
+ define_method(action) { |*args| exec_action(action) }
72
+ end
73
+
74
+ desc "test [all|<REGEX>)] [opts]", "Test one or more instances"
75
+ long_desc <<-DESC
76
+ Test one or more instances
77
+
78
+ There are 3 post-verify modes for instance cleanup, triggered with
79
+ the `--destroy' flag:
80
+
81
+ * passing: instances passing verify will be destroyed afterwards.\n
82
+ * always: instances will always be destroyed afterwards.\n
83
+ * never: instances will never be destroyed afterwards.
84
+ DESC
85
+ method_option :parallel, :aliases => "-p", :type => :boolean,
86
+ :desc => "Perform action against all matching instances in parallel"
87
+ method_option :destroy, :aliases => "-d", :default => "passing",
88
+ :desc => "Destroy strategy to use after testing (passing, always, never)."
89
+ def test(*args)
90
+ if ! %w{passing always never}.include?(options[:destroy])
91
+ raise ArgumentError, "Destroy mode must be passing, always, or never."
92
+ end
93
+
94
+ banner "Starting Kitchen"
95
+ elapsed = Benchmark.measure do
96
+ destroy_mode = options[:destroy].to_sym
97
+ @task = :test
98
+ results = parse_subcommand(args.first)
99
+
100
+ if options[:parallel]
101
+ run_parallel(results, destroy_mode)
102
+ else
103
+ run_serial(results, destroy_mode)
104
+ end
105
+ end
106
+ banner "Kitchen is finished. #{Util.duration(elapsed.real)}"
107
+ end
108
+
109
+ desc "login (['REGEX']|[INSTANCE])", "Log in to one instance"
110
+ def login(regexp)
111
+ results = get_filtered_instances(regexp)
112
+ if results.size > 1
113
+ die task, "Argument `#{regexp}' returned multiple results:\n" +
114
+ results.map { |i| " * #{i.name}" }.join("\n")
115
+ end
116
+ instance = results.pop
117
+
118
+ instance.login
119
+ end
120
+
121
+ desc "version", "Print Kitchen's version information"
122
+ def version
123
+ say "Kitchen version #{Kitchen::VERSION}"
124
+ end
125
+ map %w(-v --version) => :version
126
+
127
+ desc "console", "Kitchen Console!"
128
+ def console
129
+ require 'pry'
130
+ Pry.start(@config, :prompt => pry_prompts)
131
+ rescue LoadError => e
132
+ warn %{Make sure you have the pry gem installed. You can install it with:}
133
+ warn %{`gem install pry` or including 'gem "pry"' in your Gemfile.}
134
+ exit 1
135
+ end
136
+
137
+ desc "init", "Adds some configuration to your cookbook so Kitchen can rock"
138
+ def init
139
+ InitGenerator.new.init
140
+ end
141
+
142
+ desc "new_plugin [NAME]", "Generate a new Kitchen Driver plugin gem project"
143
+ method_option :license, :aliases => "-l", :default => "apachev2",
144
+ :desc => "Type of license for gem (apachev2, mit, gplv3, gplv2, reserved)"
145
+ def new_plugin(name)
146
+ g = NewPluginGenerator.new
147
+ g.options = options
148
+ g.new_plugin(name)
149
+ end
150
+
151
+ private
152
+
153
+ attr_reader :task
154
+
155
+ def logger
156
+ Kitchen.logger
157
+ end
158
+
159
+ def exec_action(action)
160
+ banner "Starting Kitchen"
161
+ elapsed = Benchmark.measure do
162
+ @task = action
163
+ results = parse_subcommand(args.first)
164
+ options[:parallel] ? run_parallel(results) : run_serial(results)
165
+ end
166
+ banner "Kitchen is finished. #{Util.duration(elapsed.real)}"
167
+ end
168
+
169
+ def run_serial(instances, *args)
170
+ Array(instances).map { |i| i.public_send(task, *args) }
171
+ end
172
+
173
+ def run_parallel(instances, *args)
174
+ futures = Array(instances).map { |i| i.future.public_send(task) }
175
+ futures.map { |i| i.value }
176
+ end
177
+
178
+ def parse_subcommand(arg = nil)
179
+ arg == "all" ? get_all_instances : get_filtered_instances(arg)
180
+ end
181
+
182
+ def get_all_instances
183
+ result = @config.instances
184
+ if result.empty?
185
+ die task, "No instances defined"
186
+ else
187
+ result
188
+ end
189
+ end
190
+
191
+ def get_filtered_instances(regexp)
192
+ result = if options[:parallel]
193
+ @config.instance_actors(/#{regexp}/)
194
+ else
195
+ @config.instances.get_all(/#{regexp}/)
196
+ end
197
+
198
+ if result.empty?
199
+ die task, "No instances for regex `#{regexp}', try running `kitchen list'"
200
+ else
201
+ result
202
+ end
203
+ end
204
+
205
+ def display_instance(instance)
206
+ action = case instance.last_action
207
+ when 'create' then set_color("Created", :cyan)
208
+ when 'converge' then set_color("Converged", :magenta)
209
+ when 'setup' then set_color("Set Up", :blue)
210
+ when 'verify' then set_color("Verified", :yellow)
211
+ when nil then set_color("<Not Created>", :red)
212
+ else set_color("<Unknown>", :white)
213
+ end
214
+ [set_color(instance.name, :white), action]
215
+ end
216
+
217
+ def die(task, msg)
218
+ error "\n#{msg}\n\n"
219
+ help(task)
220
+ exit 1
221
+ end
222
+
223
+ def pry_prompts
224
+ [
225
+ proc { |target_self, nest_level, pry|
226
+ ["[#{pry.input_array.size}] ",
227
+ "jc(#{Pry.view_clip(target_self.class)})",
228
+ "#{":#{nest_level}" unless nest_level.zero?}> "
229
+ ].join
230
+ },
231
+ proc { |target_self, nest_level, pry|
232
+ ["[#{pry.input_array.size}] ",
233
+ "jc(#{Pry.view_clip(target_self.class)})",
234
+ "#{":#{nest_level}" unless nest_level.zero?}* "
235
+ ].join
236
+ },
237
+ ]
238
+ end
239
+ end
240
+
241
+ # A project initialization generator, to help prepare a cookbook project for
242
+ # testing with Kitchen.
243
+ #
244
+ # @author Fletcher Nichol <fnichol@nichol.ca>
245
+ class InitGenerator < Thor
246
+
247
+ include Thor::Actions
248
+
249
+ desc "init", "Adds some configuration to your cookbook so Kitchen can rock"
250
+ def init
251
+ create_file ".kitchen.yml", default_yaml
252
+
253
+ rakedoc = <<-RAKE.gsub(/^ {8}/, '')
254
+
255
+ begin
256
+ require 'kitchen/rake_tasks'
257
+ Kitchen::RakeTasks.new
258
+ rescue LoadError
259
+ puts ">>>>> Kitchen gem not loaded, omitting tasks" unless ENV['CI']
260
+ end
261
+ RAKE
262
+ append_to_file("Rakefile", rakedoc) if init_rakefile?
263
+
264
+ thordoc = <<-THOR.gsub(/^ {8}/, '')
265
+
266
+ begin
267
+ require 'kitchen/thor_tasks'
268
+ Kitchen::ThorTasks.new
269
+ rescue LoadError
270
+ puts ">>>>> Kitchen gem not loaded, omitting tasks" unless ENV['CI']
271
+ end
272
+ THOR
273
+ append_to_file("Thorfile", thordoc) if init_thorfile?
274
+
275
+ empty_directory "test/integration/default" if init_test_dir?
276
+ append_to_gitignore(".kitchen/")
277
+ append_to_gitignore(".kitchen.local.yml")
278
+ add_plugins
279
+ end
280
+
281
+ private
282
+
283
+ def default_yaml
284
+ url_base = "https://opscode-vm.s3.amazonaws.com/vagrant/boxes"
285
+ platforms = [
286
+ { :n => 'ubuntu', :vers => %w(12.04 10.04), :rl => "recipe[apt]" },
287
+ { :n => 'centos', :vers => %w(6.3 5.8), :rl => "recipe[yum::epel]" },
288
+ ]
289
+ platforms = platforms.map do |p|
290
+ p[:vers].map do |v|
291
+ { 'name' => "#{p[:n]}-#{v}",
292
+ 'driver_config' => {
293
+ 'box' => "opscode-#{p[:n]}-#{v}",
294
+ 'box_url' => "#{url_base}/opscode-#{p[:n]}-#{v}.box"
295
+ },
296
+ 'run_list' => Array(p[:rl])
297
+ }
298
+ end
299
+ end.flatten
300
+ cookbook_name = if File.exists?(File.expand_path('metadata.rb'))
301
+ MetadataChopper.extract('metadata.rb').first
302
+ else
303
+ nil
304
+ end
305
+ run_list = cookbook_name ? "recipe[#{cookbook_name}]" : nil
306
+
307
+ { 'driver_plugin' => 'vagrant',
308
+ 'platforms' => platforms,
309
+ 'suites' => [
310
+ { 'name' => 'default',
311
+ 'run_list' => Array(run_list),
312
+ 'attributes' => Hash.new
313
+ },
314
+ ]
315
+ }.to_yaml
316
+ end
317
+
318
+ def init_rakefile?
319
+ File.exists?("Rakefile") &&
320
+ IO.readlines("Rakefile").grep(%r{require 'kitchen/rake_tasks'}).empty?
321
+ end
322
+
323
+ def init_thorfile?
324
+ File.exists?("Thorfile") &&
325
+ IO.readlines("Thorfile").grep(%r{require 'kitchen/thor_tasks'}).empty?
326
+ end
327
+
328
+ def init_test_dir?
329
+ Dir.glob("test/integration/*").select { |d| File.directory?(d) }.empty?
330
+ end
331
+
332
+ def append_to_gitignore(line)
333
+ create_file(".gitignore") unless File.exists?(".gitignore")
334
+
335
+ if IO.readlines(".gitignore").grep(%r{^#{line}}).empty?
336
+ append_to_file(".gitignore", "#{line}\n")
337
+ end
338
+ end
339
+
340
+ def add_plugins
341
+ prompt_add = "Add a Driver plugin to your Gemfile? (y/n)>"
342
+ prompt_name = "Enter gem name, `list', or `skip'>"
343
+
344
+ if yes?(prompt_add, :green)
345
+ list_plugins while (plugin = ask(prompt_name, :green)) == "list"
346
+ return if plugin == "skip"
347
+ begin
348
+ append_to_file(
349
+ "Gemfile", %{gem '#{plugin}', :group => :integration\n}
350
+ )
351
+ say "You must run `bundle install' to fetch any new gems.", :red
352
+ rescue Errno::ENOENT
353
+ warn %{You do not have an existing Gemfile}
354
+ warn %{Exiting...}
355
+ exit 1
356
+ end
357
+ end
358
+ end
359
+
360
+ def list_plugins
361
+ specs = fetch_gem_specs.map { |t| t.first }.map { |t| t[0, 2] }.
362
+ sort { |x, y| x[0] <=> y[0] }
363
+ specs = specs[0, 49].push(["...", "..."]) if specs.size > 49
364
+ specs = specs.unshift(["Gem Name", "Latest Stable Release"])
365
+ print_table(specs, :indent => 4)
366
+ end
367
+
368
+ def fetch_gem_specs
369
+ require 'rubygems/spec_fetcher'
370
+ req = Gem::Requirement.default
371
+ dep = Gem::Deprecate.skip_during { Gem::Dependency.new(/kitchen-/i, req) }
372
+ fetcher = Gem::SpecFetcher.fetcher
373
+
374
+ specs = fetcher.find_matching(dep, false, false, false)
375
+ end
376
+ end
377
+
378
+ # A generator to create a new Kitchen driver plugin.
379
+ #
380
+ # @author Fletcher Nichol <fnichol@nichol.ca>
381
+ class NewPluginGenerator < Thor
382
+
383
+ include Thor::Actions
384
+
385
+ desc "new_plugin [NAME]", "Generate a new Kitchen Driver plugin gem project"
386
+ method_option :license, :aliases => "-l", :default => "apachev2",
387
+ :desc => "Type of license for gem (apachev2, mit, gplv3, gplv2, reserved)"
388
+ def new_plugin(plugin_name)
389
+ if ! run("command -v bundle", :verbose => false)
390
+ die "Bundler must be installed and on your PATH: `gem install bundler'"
391
+ end
392
+
393
+ @plugin_name = plugin_name
394
+ @gem_name = "kitchen-#{plugin_name}"
395
+ @gemspec = "#{gem_name}.gemspec"
396
+ @klass_name = Util.to_camel_case(plugin_name)
397
+ @constant = Util.to_snake_case(plugin_name).upcase
398
+ @license = options[:license]
399
+ @author = %x{git config user.name}.chomp
400
+ @email = %x{git config user.email}.chomp
401
+ @year = Time.now.year
402
+
403
+ create_plugin
404
+ end
405
+
406
+ private
407
+
408
+ attr_reader :plugin_name, :gem_name, :gemspec, :klass_name,
409
+ :constant, :license, :author, :email, :year
410
+
411
+ def create_plugin
412
+ run("bundle gem #{gem_name}") unless File.directory?(gem_name)
413
+
414
+ inside(gem_name) do
415
+ update_gemspec
416
+ update_gemfile
417
+ update_rakefile
418
+ create_src_files
419
+ cleanup
420
+ create_license
421
+ add_git_files
422
+ end
423
+ end
424
+
425
+ def update_gemspec
426
+ gsub_file(gemspec, %r{require '#{gem_name}/version'},
427
+ %{require 'kitchen/driver/#{plugin_name}_version.rb'})
428
+ gsub_file(gemspec, %r{Kitchen::#{klass_name}::VERSION},
429
+ %{Kitchen::Driver::#{constant}_VERSION})
430
+ gsub_file(gemspec, %r{(gem\.executables\s*) =.*$},
431
+ '\1 = []')
432
+ gsub_file(gemspec, %r{(gem\.description\s*) =.*$},
433
+ '\1 = "' + "Kitchen::Driver::#{klass_name} - " +
434
+ "A Kitchen Driver for #{klass_name}\"")
435
+ gsub_file(gemspec, %r{(gem\.summary\s*) =.*$},
436
+ '\1 = gem.description')
437
+ gsub_file(gemspec, %r{(gem\.homepage\s*) =.*$},
438
+ '\1 = "https://github.com/opscode/' +
439
+ "#{gem_name}/\"")
440
+ insert_into_file(gemspec,
441
+ "\n gem.add_dependency 'test-kitchen'\n", :before => "end\n")
442
+ insert_into_file(gemspec,
443
+ "\n gem.add_development_dependency 'cane'\n", :before => "end\n")
444
+ insert_into_file(gemspec,
445
+ " gem.add_development_dependency 'tailor'\n", :before => "end\n")
446
+ end
447
+
448
+ def update_gemfile
449
+ append_to_file("Gemfile", "\ngroup :test do\n gem 'rake'\nend\n")
450
+ end
451
+
452
+ def update_rakefile
453
+ append_to_file("Rakefile", <<-RAKEFILE.gsub(/^ {8}/, ''))
454
+ require 'cane/rake_task'
455
+ require 'tailor/rake_task'
456
+
457
+ desc "Run cane to check quality metrics"
458
+ Cane::RakeTask.new
459
+
460
+ Tailor::RakeTask.new
461
+
462
+ task :default => [ :cane, :tailor ]
463
+ RAKEFILE
464
+ end
465
+
466
+ def create_src_files
467
+ license_comments = rendered_license.gsub(/^/, '# ').gsub(/\s+$/, '')
468
+
469
+ empty_directory("lib/kitchen/driver")
470
+ create_template("plugin/version.rb",
471
+ "lib/kitchen/driver/#{plugin_name}_version.rb",
472
+ :klass_name => klass_name, :constant => constant,
473
+ :license => license_comments)
474
+ create_template("plugin/driver.rb",
475
+ "lib/kitchen/driver/#{plugin_name}.rb",
476
+ :klass_name => klass_name, :license => license_comments,
477
+ :author => author, :email => email)
478
+ end
479
+
480
+ def rendered_license
481
+ TemplateRenderer.render("plugin/license_#{license}",
482
+ :author => author, :email => email, :year => year)
483
+ end
484
+
485
+ def create_license
486
+ dest_file = case license
487
+ when "mit" then "LICENSE.txt"
488
+ when "apachev2", "reserved" then "LICENSE"
489
+ when "gplv2", "gplv3" then "COPYING"
490
+ else
491
+ raise ArgumentError, "No such license #{license}"
492
+ end
493
+
494
+ create_file(dest_file, rendered_license)
495
+ end
496
+
497
+ def cleanup
498
+ %W(LICENSE.txt lib/#{gem_name}/version.rb lib/#{gem_name}.rb).each do |f|
499
+ run("git rm -f #{f}") if File.exists?(f)
500
+ end
501
+ remove_dir("lib/#{gem_name}")
502
+ end
503
+
504
+ def add_git_files
505
+ run("git add .")
506
+ end
507
+
508
+ def create_template(template, destination, data = {})
509
+ create_file(destination, TemplateRenderer.render(template, data))
510
+ end
511
+
512
+ # Renders an ERB template with a hash of template variables.
513
+ #
514
+ # @author Fletcher Nichol <fnichol@nichol.ca>
515
+ class TemplateRenderer < OpenStruct
516
+
517
+ def self.render(template, data = {})
518
+ renderer = new(template, data)
519
+ yield renderer if block_given?
520
+ renderer.render
521
+ end
522
+
523
+ def initialize(template, data = {})
524
+ super()
525
+ data[:template] = template
526
+ data.each { |key, value| send("#{key}=", value) }
527
+ end
528
+
529
+ def render
530
+ ERB.new(IO.read(template_file)).result(binding)
531
+ end
532
+
533
+ private
534
+
535
+ def template_file
536
+ Kitchen.source_root.join("templates", "#{template}.erb").to_s
537
+ end
538
+ end
539
+ end
540
+ end