chef-metal 0.1

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 (34) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/README.md +160 -0
  4. data/Rakefile +6 -0
  5. data/lib/chef/provider/fog_key_pair.rb +106 -0
  6. data/lib/chef/provider/machine.rb +60 -0
  7. data/lib/chef/provider/machine_file.rb +39 -0
  8. data/lib/chef/provider/vagrant_box.rb +44 -0
  9. data/lib/chef/provider/vagrant_cluster.rb +39 -0
  10. data/lib/chef/resource/fog_key_pair.rb +34 -0
  11. data/lib/chef/resource/machine.rb +56 -0
  12. data/lib/chef/resource/machine_file.rb +25 -0
  13. data/lib/chef/resource/vagrant_box.rb +18 -0
  14. data/lib/chef/resource/vagrant_cluster.rb +16 -0
  15. data/lib/chef_metal.rb +84 -0
  16. data/lib/chef_metal/aws_credentials.rb +55 -0
  17. data/lib/chef_metal/convergence_strategy.rb +15 -0
  18. data/lib/chef_metal/convergence_strategy/install_msi.rb +41 -0
  19. data/lib/chef_metal/convergence_strategy/install_sh.rb +36 -0
  20. data/lib/chef_metal/convergence_strategy/precreate_chef_objects.rb +140 -0
  21. data/lib/chef_metal/inline_resource.rb +88 -0
  22. data/lib/chef_metal/machine.rb +79 -0
  23. data/lib/chef_metal/machine/basic_machine.rb +79 -0
  24. data/lib/chef_metal/machine/unix_machine.rb +108 -0
  25. data/lib/chef_metal/machine/windows_machine.rb +94 -0
  26. data/lib/chef_metal/provisioner.rb +71 -0
  27. data/lib/chef_metal/provisioner/fog_provisioner.rb +378 -0
  28. data/lib/chef_metal/provisioner/vagrant_provisioner.rb +327 -0
  29. data/lib/chef_metal/recipe_dsl.rb +26 -0
  30. data/lib/chef_metal/transport.rb +36 -0
  31. data/lib/chef_metal/transport/ssh.rb +157 -0
  32. data/lib/chef_metal/transport/winrm.rb +91 -0
  33. data/lib/chef_metal/version.rb +3 -0
  34. metadata +175 -0
@@ -0,0 +1,327 @@
1
+ require 'chef/mixin/shell_out'
2
+ require 'chef_metal/provisioner'
3
+
4
+ module ChefMetal
5
+ class Provisioner
6
+
7
+ # Provisions machines in vagrant.
8
+ class VagrantProvisioner < Provisioner
9
+
10
+ include Chef::Mixin::ShellOut
11
+
12
+ # Create a new vagrant provisioner.
13
+ #
14
+ # ## Parameters
15
+ # cluster_path - path to the directory containing the vagrant files, which
16
+ # should have been created with the vagrant_cluster resource.
17
+ def initialize(cluster_path)
18
+ @cluster_path = cluster_path
19
+ end
20
+
21
+ attr_reader :cluster_path
22
+
23
+ # Acquire a machine, generally by provisioning it. Returns a Machine
24
+ # object pointing at the machine, allowing useful actions like setup,
25
+ # converge, execute, file and directory. The Machine object will have a
26
+ # "node" property which must be saved to the server (if it is any
27
+ # different from the original node object).
28
+ #
29
+ # ## Parameters
30
+ # provider - the provider object that is calling this method.
31
+ # node - node object (deserialized json) representing this machine. If
32
+ # the node has a provisioner_options hash in it, these will be used
33
+ # instead of options provided by the provisioner. TODO compare and
34
+ # fail if different?
35
+ # node will have node['normal']['provisioner_options'] in it with any options.
36
+ # It is a hash with this format:
37
+ #
38
+ # -- provisioner_url: vagrant:<cluster_path>
39
+ # -- vagrant_options: hash of properties of the "config"
40
+ # object, i.e. "vm.box" => "ubuntu12" and "vm.box_url"
41
+ # -- vagrant_config: string containing other vagrant config.
42
+ # Should assume the variable "config" represents machine config.
43
+ # Will be written verbatim into the vm's Vagrantfile.
44
+ # -- transport_options: hash of options specifying the transport.
45
+ # :type => :ssh
46
+ # :type => :winrm
47
+ # If not specified, ssh is used unless vm.guest is :windows. If that is
48
+ # the case, the windows options are used and the port forward for 5985
49
+ # is detected.
50
+ # -- up_timeout: maximum time, in seconds, to wait for vagrant
51
+ # to bring up the machine. Defaults to 10 minutes.
52
+ #
53
+ # node['normal']['provisioner_output'] will be populated with information
54
+ # about the created machine. For vagrant, it is a hash with this
55
+ # format:
56
+ #
57
+ # -- provisioner_url: vagrant_cluster://<current_node>/<cluster_path>
58
+ # -- vm_name: name of vagrant vm created
59
+ # -- vm_file_path: path to machine-specific vagrant config file
60
+ # on disk
61
+ # -- forwarded_ports: hash with key as guest_port => host_port
62
+ #
63
+ def acquire_machine(provider, node)
64
+ # Set up the modified node data
65
+ provisioner_options = node['normal']['provisioner_options']
66
+ vm_name = node['name']
67
+ old_provisioner_output = node['normal']['provisioner_output']
68
+ node['normal']['provisioner_output'] = provisioner_output = {
69
+ 'provisioner_url' => provisioner_url(provider),
70
+ 'vm_name' => vm_name,
71
+ 'vm_file_path' => File.join(cluster_path, "#{vm_name}.vm")
72
+ }
73
+ # Preserve existing forwarded ports
74
+ provisioner_output['forwarded_ports'] = old_provisioner_output['forwarded_ports'] if old_provisioner_output
75
+
76
+ # TODO compare new options to existing and fail if we cannot change it
77
+ # over (perhaps introduce a boolean that will force a delete and recreate
78
+ # in such a case)
79
+
80
+ # Determine contents of vm file
81
+ vm_file_content = "Vagrant.configure('2') do |outer_config|\n"
82
+ vm_file_content << " outer_config.vm.define #{vm_name.inspect} do |config|\n"
83
+ merged_vagrant_options = { 'vm.hostname' => node['name'] }
84
+ merged_vagrant_options.merge!(provisioner_options['vagrant_options']) if provisioner_options['vagrant_options']
85
+ merged_vagrant_options.each_pair do |key, value|
86
+ vm_file_content << " config.#{key} = #{value.inspect}\n"
87
+ end
88
+ vm_file_content << provisioner_options['vagrant_config'] if provisioner_options['vagrant_config']
89
+ vm_file_content << " end\nend\n"
90
+
91
+ # Set up vagrant file
92
+ vm_file = ChefMetal.inline_resource(provider) do
93
+ file provisioner_output['vm_file_path'] do
94
+ content vm_file_content
95
+ action :create
96
+ end
97
+ end
98
+
99
+ # Check current status of vm
100
+ current_status = vagrant_status(vm_name)
101
+ up_timeout = provisioner_options['up_timeout'] || 10*60
102
+
103
+ if current_status != 'running'
104
+ # Run vagrant up if vm is not running
105
+ provider.converge_by "run vagrant up #{vm_name} (status was '#{current_status}')" do
106
+ result = shell_out("vagrant up #{vm_name}", :cwd => cluster_path, :timeout => up_timeout)
107
+ if result.exitstatus != 0
108
+ raise "vagrant up #{vm_name} failed!\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}"
109
+ end
110
+ parse_vagrant_up(result.stdout, node)
111
+ end
112
+ elsif vm_file.updated_by_last_action?
113
+ # Run vagrant reload if vm is running and vm file changed
114
+ provider.converge_by "run vagrant reload #{vm_name}" do
115
+ result = shell_out("vagrant reload #{vm_name}", :cwd => cluster_path, :timeout => up_timeout)
116
+ if result.exitstatus != 0
117
+ raise "vagrant reload #{vm_name} failed!\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}"
118
+ end
119
+ parse_vagrant_up(result.stdout, node)
120
+ end
121
+ end
122
+
123
+ # Create machine object for callers to use
124
+ machine_for(node)
125
+ end
126
+
127
+ # Connect to machine without acquiring it
128
+ def connect_to_machine(node)
129
+ machine_for(node)
130
+ end
131
+
132
+ def delete_machine(provider, node)
133
+ if node['normal'] && node['normal']['provisioner_output']
134
+ provisioner_output = node['normal']['provisioner_output']
135
+ else
136
+ provisioner_output = {}
137
+ end
138
+ vm_name = provisioner_output['vm_name'] || node['name']
139
+ current_status = vagrant_status(vm_name)
140
+ if current_status != 'not created'
141
+ provider.converge_by "run vagrant destroy -f #{vm_name} (status was '#{current_status}')" do
142
+ result = shell_out("vagrant destroy -f #{vm_name}", :cwd => cluster_path)
143
+ if result.exitstatus != 0
144
+ raise "vagrant destroy failed!\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}"
145
+ end
146
+ end
147
+ end
148
+
149
+ convergence_strategy_for(node).delete_chef_objects(provider, node)
150
+
151
+ vm_file_path = provisioner_output['vm_file_path'] || File.join(cluster_path, "#{vm_name}.vm")
152
+ ChefMetal.inline_resource(provider) do
153
+ file vm_file_path do
154
+ action :delete
155
+ end
156
+ end
157
+ end
158
+
159
+ def stop_machine(provider, node)
160
+ if node['normal'] && node['normal']['provisioner_output']
161
+ provisioner_output = node['normal']['provisioner_output']
162
+ else
163
+ provisioner_output = {}
164
+ end
165
+ vm_name = provisioner_output['vm_name'] || node['name']
166
+ current_status = vagrant_status(vm_name)
167
+ if current_status == 'running'
168
+ provider.converge_by "run vagrant halt #{vm_name} (status was '#{current_status}')" do
169
+ result = shell_out("vagrant halt #{vm_name}", :cwd => cluster_path)
170
+ if result.exitstatus != 0
171
+ raise "vagrant halt failed!\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}"
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+
178
+ # Used by vagrant_cluster and machine to get the string used to configure vagrant
179
+ def self.vagrant_config_string(vagrant_config, variable, line_prefix)
180
+ hostname = name.gsub(/[^A-Za-z0-9\-]/, '-')
181
+
182
+ result = ''
183
+ vagrant_config.each_pair do |key, value|
184
+ result += "#{line_prefix}#{variable}.#{key} = #{value.inspect}\n"
185
+ end
186
+ result
187
+ end
188
+
189
+ protected
190
+
191
+ def provisioner_url(provider)
192
+ "vagrant_cluster://#{provider.node['name']}#{cluster_path}"
193
+ end
194
+
195
+ def parse_vagrant_up(output, node)
196
+ # Grab forwarded port info
197
+ in_forwarding_ports = false
198
+ output.lines.each do |line|
199
+ if in_forwarding_ports
200
+ if line =~ /-- (\d+) => (\d+)/
201
+ node['normal']['provisioner_output']['forwarded_ports'][$1] = $2
202
+ else
203
+ in_forwarding_ports = false
204
+ end
205
+ elsif line =~ /Forwarding ports...$/
206
+ node['normal']['provisioner_output']['forwarded_ports'] = {}
207
+ in_forwarding_ports = true
208
+ end
209
+ end
210
+ end
211
+
212
+ def machine_for(node)
213
+ if vagrant_option(node, 'vm.guest').to_s == 'windows'
214
+ require 'chef_metal/machine/windows_machine'
215
+ ChefMetal::Machine::WindowsMachine.new(node, transport_for(node), convergence_strategy_for(node))
216
+ else
217
+ require 'chef_metal/machine/unix_machine'
218
+ ChefMetal::Machine::UnixMachine.new(node, transport_for(node), convergence_strategy_for(node))
219
+ end
220
+ end
221
+
222
+ def convergence_strategy_for(node)
223
+ if vagrant_option(node, 'vm.guest').to_s == 'windows'
224
+ require 'chef_metal/convergence_strategy/install_msi'
225
+ ChefMetal::ConvergenceStrategy::InstallMsi.new
226
+ else
227
+ require 'chef_metal/convergence_strategy/install_sh'
228
+ ChefMetal::ConvergenceStrategy::InstallSh.new
229
+ end
230
+ end
231
+
232
+ def transport_for(node)
233
+ if vagrant_option(node, 'vm.guest').to_s == 'windows'
234
+ create_winrm_transport(node)
235
+ else
236
+ create_ssh_transport(node)
237
+ end
238
+ end
239
+
240
+ def vagrant_option(node, option)
241
+ if node['normal']['provisioner_options'] &&
242
+ node['normal']['provisioner_options']['vagrant_options']
243
+ node['normal']['provisioner_options']['vagrant_options'][option]
244
+ else
245
+ nil
246
+ end
247
+ end
248
+
249
+ def vagrant_status(name)
250
+ status_output = shell_out("vagrant status #{name}", :cwd => cluster_path).stdout
251
+ if status_output =~ /^#{name}\s+([^\n]+)\s+\(([^\n]+)\)$/m
252
+ $1
253
+ else
254
+ 'not created'
255
+ end
256
+ end
257
+
258
+ def create_winrm_transport(node)
259
+ require 'chef_metal/transport/winrm'
260
+
261
+ provisioner_output = node['default']['provisioner_output'] || {}
262
+ forwarded_ports = provisioner_output['forwarded_ports'] || {}
263
+
264
+ # TODO IPv6 loopback? What do we do for that?
265
+ hostname = vagrant_option(node, 'winrm.host') || '127.0.0.1'
266
+ port = vagrant_option(node, 'winrm.port') || forwarded_ports[5985] || 5985
267
+ endpoint = "http://#{hostname}:#{port}/wsman"
268
+ type = :plaintext
269
+ options = {
270
+ :user => vagrant_option(node, 'winrm.username') || 'vagrant',
271
+ :pass => vagrant_option(node, 'winrm.password') || 'vagrant',
272
+ :disable_sspi => true
273
+ }
274
+
275
+ ChefMetal::Transport::WinRM.new(endpoint, type, options)
276
+ end
277
+
278
+ def create_ssh_transport(node)
279
+ require 'chef_metal/transport/ssh'
280
+
281
+ vagrant_ssh_config = vagrant_ssh_config_for(node)
282
+ hostname = vagrant_ssh_config['HostName']
283
+ username = vagrant_ssh_config['User']
284
+ ssh_options = {
285
+ :port => vagrant_ssh_config['Port'],
286
+ :auth_methods => ['publickey'],
287
+ :user_known_hosts_file => vagrant_ssh_config['UserKnownHostsFile'],
288
+ :paranoid => yes_or_no(vagrant_ssh_config['StrictHostKeyChecking']),
289
+ :keys => [ strip_quotes(vagrant_ssh_config['IdentityFile']) ],
290
+ :keys_only => yes_or_no(vagrant_ssh_config['IdentitiesOnly'])
291
+ }
292
+ ssh_options[:auth_methods] = %w(password) if yes_or_no(vagrant_ssh_config['PasswordAuthentication'])
293
+ options = {
294
+ :prefix => 'sudo '
295
+ }
296
+ ChefMetal::Transport::SSH.new(hostname, username, ssh_options, options)
297
+ end
298
+
299
+ def vagrant_ssh_config_for(node)
300
+ vagrant_ssh_config = {}
301
+ result = shell_out("vagrant ssh-config #{node['normal']['provisioner_output']['vm_name']}", :cwd => cluster_path)
302
+ result.stdout.lines.inject({}) do |result, line|
303
+ line =~ /^\s*(\S+)\s+(.+)/
304
+ vagrant_ssh_config[$1] = $2
305
+ end
306
+ vagrant_ssh_config
307
+ end
308
+
309
+ def yes_or_no(str)
310
+ case str
311
+ when 'yes'
312
+ true
313
+ else
314
+ false
315
+ end
316
+ end
317
+
318
+ def strip_quotes(str)
319
+ if str[0] == '"' && str[-1] == '"' && str.size >= 2
320
+ str[1..-2]
321
+ else
322
+ str
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,26 @@
1
+ require 'chef_metal'
2
+ require 'chef_metal/provisioner/fog_provisioner'
3
+
4
+ class Chef
5
+ class Recipe
6
+ def with_provisioner(provisioner, &block)
7
+ ChefMetal.with_provisioner(provisioner, &block)
8
+ end
9
+
10
+ def with_provisioner_options(provisioner_options, &block)
11
+ ChefMetal.with_provisioner_options(provisioner_options, &block)
12
+ end
13
+
14
+ def with_vagrant_cluster(cluster_path, &block)
15
+ ChefMetal.with_vagrant_cluster(cluster_path, &block)
16
+ end
17
+
18
+ def with_vagrant_box(box_name, vagrant_options = {}, &block)
19
+ ChefMetal.with_vagrant_box(box_name, vagrant_options, &block)
20
+ end
21
+
22
+ def with_fog_ec2_provisioner(options = {}, &block)
23
+ ChefMetal.with_provisioner(ChefMetal::Provisioner::FogProvisioner.new({ :provider => 'AWS' }.merge(options)), &block)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ module ChefMetal
2
+ class Transport
3
+ def execute(command)
4
+ raise "execute not overridden on #{self.class}"
5
+ end
6
+
7
+ def read_file(path)
8
+ raise "read_file not overridden on #{self.class}"
9
+ end
10
+
11
+ def write_file(path, content)
12
+ raise "write_file not overridden on #{self.class}"
13
+ end
14
+
15
+ def download_file(path, local_path)
16
+ IO.write(local_path, read_file(path))
17
+ end
18
+
19
+ def upload_file(local_path, path)
20
+ write_file(path, IO.read(local_path))
21
+ end
22
+
23
+ # Forward requests to a port on the guest to a server on the host
24
+ def forward_remote_port_to_local(remote_port, local_port)
25
+ raise "forward_remote_port_to_local not overridden on #{self.class}"
26
+ end
27
+
28
+ def disconnect
29
+ raise "disconnect not overridden on #{self.class}"
30
+ end
31
+
32
+ def available?
33
+ raise "available? not overridden on #{self.class}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,157 @@
1
+ require 'chef_metal/transport'
2
+
3
+ module ChefMetal
4
+ class Transport
5
+ class SSH < Transport
6
+ def initialize(host, username, ssh_options, options)
7
+ require 'net/ssh'
8
+ require 'net/scp'
9
+ @host = host
10
+ @username = username
11
+ @ssh_options = ssh_options
12
+ @options = options
13
+ end
14
+
15
+ attr_reader :host
16
+ attr_reader :username
17
+ attr_reader :ssh_options
18
+ attr_reader :options
19
+
20
+ def execute(command)
21
+ Chef::Log.info("Executing #{command} on #{username}@#{host}")
22
+ stdout = ''
23
+ stderr = ''
24
+ exitstatus = nil
25
+ channel = session.open_channel do |channel|
26
+ channel.exec("#{options[:prefix]}#{command}") do |ch, success|
27
+ raise "could not execute command: #{command.inspect}" unless success
28
+
29
+ channel.on_data do |ch2, data|
30
+ stdout << data
31
+ end
32
+
33
+ channel.on_extended_data do |ch2, type, data|
34
+ stderr << data
35
+ end
36
+
37
+ channel.on_request "exit-status" do |ch, data|
38
+ exitstatus = data.read_long
39
+ end
40
+ end
41
+ end
42
+
43
+ channel.wait
44
+
45
+ Chef::Log.info("Completed #{command} on #{username}@#{host}: exit status #{exitstatus}")
46
+ Chef::Log.debug("Stdout was:\n#{stdout}") if stdout != ''
47
+ Chef::Log.info("Stderr was:\n#{stderr}") if stderr != ''
48
+ SSHResult.new(stdout, stderr, exitstatus)
49
+ end
50
+
51
+ def read_file(path)
52
+ Chef::Log.debug("Reading file #{path} from #{username}@#{host}")
53
+ result = StringIO.new
54
+ download(path, result)
55
+ result.string
56
+ end
57
+
58
+ def download_file(path, local_path)
59
+ Chef::Log.debug("Downloading file #{path} from #{username}@#{host} to local #{local_path}")
60
+ download(path, local_path)
61
+ end
62
+
63
+ def write_file(path, content)
64
+ if options[:prefix]
65
+ # Make a tempfile on the other side, upload to that, and sudo mv / chown / etc.
66
+ remote_tempfile = "/tmp/#{File.basename(path)}.#{Random.rand(2**32)}"
67
+ Chef::Log.debug("Writing #{content.length} bytes to #{remote_tempfile} on #{username}@#{host}")
68
+ Net::SCP.new(session).upload!(StringIO.new(content), remote_tempfile)
69
+ execute("mv #{remote_tempfile} #{path}")
70
+ else
71
+ Chef::Log.debug("Writing #{content.length} bytes to #{path} on #{username}@#{host}")
72
+ Net::SCP.new(session).upload!(StringIO.new(content), path)
73
+ end
74
+ end
75
+
76
+ def upload_file(local_path, path)
77
+ if options[:prefix]
78
+ # Make a tempfile on the other side, upload to that, and sudo mv / chown / etc.
79
+ remote_tempfile = "/tmp/#{File.basename(path)}.#{Random.rand(2**32)}"
80
+ Chef::Log.debug("Uploading #{local_path} to #{remote_tempfile} on #{username}@#{host}")
81
+ Net::SCP.new(session).upload!(local_path, remote_tempfile)
82
+ execute("mv #{remote_tempfile} #{path}")
83
+ else
84
+ Chef::Log.debug("Uploading #{local_path} to #{path} on #{username}@#{host}")
85
+ Net::SCP.new(session).upload!(local_path, path)
86
+ end
87
+ end
88
+
89
+ def forward_remote_port_to_local(remote_port, local_port)
90
+ # TODO IPv6
91
+ Chef::Log.debug("Forwarding local server 127.0.0.1:#{local_port} to port #{remote_port} on #{username}@#{host}")
92
+ session.forward.remote(local_port, "127.0.0.1", remote_port)
93
+ end
94
+
95
+ def disconnect
96
+ if @session
97
+ begin
98
+ Chef::Log.debug("Closing SSH session on #{username}@#{host}")
99
+ @session.close
100
+ rescue
101
+ end
102
+ @session = nil
103
+ end
104
+ end
105
+
106
+ def available?
107
+ execute('pwd')
108
+ true
109
+ rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::ECONNRESET, Net::SSH::AuthenticationFailed, Net::SSH::Disconnect, Net::SSH::HostKeyMismatch
110
+ Chef::Log.debug("#{username}@#{host} unavailable: could not execute 'pwd' on #{host}: #{$!.inspect}")
111
+ false
112
+ end
113
+
114
+ protected
115
+
116
+ def session
117
+ @session ||= begin
118
+ Chef::Log.debug("Opening SSH connection to #{username}@#{host} with options #{ssh_options.inspect}")
119
+ Net::SSH.start(host, username, ssh_options)
120
+ end
121
+ end
122
+
123
+ def download(path, local_path)
124
+ channel = Net::SCP.new(session).download(path, local_path)
125
+ begin
126
+ channel.wait
127
+ rescue Net::SCP::Error
128
+ nil
129
+ rescue
130
+ # This works around https://github.com/net-ssh/net-scp/pull/10 until a new net-scp is merged.
131
+ begin
132
+ channel.close
133
+ channel.wait
134
+ rescue Net::SCP::Error
135
+ nil
136
+ end
137
+ end
138
+ end
139
+
140
+ class SSHResult
141
+ def initialize(stdout, stderr, exitstatus)
142
+ @stdout = stdout
143
+ @stderr = stderr
144
+ @exitstatus = exitstatus
145
+ end
146
+
147
+ attr_reader :stdout
148
+ attr_reader :stderr
149
+ attr_reader :exitstatus
150
+
151
+ def error!
152
+ raise "Error: code #{exitstatus}.\nSTDOUT:#{stdout}\nSTDERR:#{stderr}" if exitstatus != 0
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end