test-kitchen 0.7.0 → 1.0.0.alpha.0

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