chef-metal-fog 0.4 → 0.5.beta

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.
@@ -1,558 +0,0 @@
1
- require 'chef_metal/provisioner'
2
- require 'chef_metal/aws_credentials'
3
- require 'chef_metal/openstack_credentials'
4
- require 'chef_metal/machine/windows_machine'
5
- require 'chef_metal/machine/unix_machine'
6
- require 'chef_metal/convergence_strategy/install_msi'
7
- require 'chef_metal/convergence_strategy/install_cached'
8
- require 'chef_metal/transport/ssh'
9
- require 'chef_metal_fog/version'
10
- require 'fog'
11
- require 'fog/core'
12
- require 'fog/compute'
13
- require 'fog/aws'
14
-
15
- module ChefMetalFog
16
- # Provisions machines in vagrant.
17
- class FogProvisioner < ChefMetal::Provisioner
18
-
19
- include Chef::Mixin::ShellOut
20
-
21
- DEFAULT_OPTIONS = {
22
- :create_timeout => 600,
23
- :start_timeout => 600,
24
- :ssh_timeout => 20
25
- }
26
-
27
- def self.inflate(node)
28
- url = node['normal']['provisioner_output']['provisioner_url']
29
- scheme, provider, id = url.split(':', 3)
30
- FogProvisioner.new({ :provider => provider }, id)
31
- end
32
-
33
- # Create a new fog provisioner.
34
- #
35
- # ## Parameters
36
- # compute_options - hash of options to be passed to Fog::Compute.new
37
- # Special options:
38
- # - :base_bootstrap_options is merged with bootstrap_options in acquire_machine
39
- # to present the full set of bootstrap options. Write down any bootstrap_options
40
- # you intend to apply universally here.
41
- # - :aws_credentials is an AWS CSV file (created with Download Credentials)
42
- # containing your aws key information. If you do not specify aws_access_key_id
43
- # and aws_secret_access_key explicitly, the first line from this file
44
- # will be used. You may pass a Cheffish::AWSCredentials object.
45
- # - :create_timeout - the time to wait for the instance to boot to ssh (defaults to 600)
46
- # - :start_timeout - the time to wait for the instance to start (defaults to 600)
47
- # - :ssh_timeout - the time to wait for ssh to be available if the instance is detected as up (defaults to 20)
48
- # id - the ID in the provisioner_url (fog:PROVIDER:ID)
49
- def initialize(compute_options, id=nil)
50
- @compute_options = compute_options
51
- @base_bootstrap_options = compute_options.delete(:base_bootstrap_options) || {}
52
-
53
- case compute_options[:provider]
54
- when 'AWS'
55
- aws_credentials = compute_options.delete(:aws_credentials)
56
- if aws_credentials
57
- @aws_credentials = aws_credentials
58
- else
59
- @aws_credentials = ChefMetal::AWSCredentials.new
60
- @aws_credentials.load_default
61
- end
62
- compute_options[:aws_access_key_id] ||= @aws_credentials.default[:access_key_id]
63
- compute_options[:aws_secret_access_key] ||= @aws_credentials.default[:secret_access_key]
64
- # TODO actually find a key with the proper id
65
- # TODO let the user specify credentials and provider profiles that we can use
66
- if id && aws_login_info[0] != id
67
- raise "Default AWS credentials point at AWS account #{aws_login_info[0]}, but inflating from URL #{id}"
68
- end
69
- when 'OpenStack'
70
- openstack_credentials = compute_options.delete(:openstack_credentials)
71
- if openstack_credentials
72
- @openstack_credentials = openstack_credentials
73
- else
74
- @openstack_credentials = ChefMetal::OpenstackCredentials.new
75
- @openstack_credentials.load_default
76
- end
77
-
78
- compute_options[:openstack_username] ||= @openstack_credentials.default[:openstack_username]
79
- compute_options[:openstack_api_key] ||= @openstack_credentials.default[:openstack_api_key]
80
- compute_options[:openstack_auth_url] ||= @openstack_credentials.default[:openstack_auth_url]
81
- compute_options[:openstack_tenant] ||= @openstack_credentials.default[:openstack_tenant]
82
- end
83
- @key_pairs = {}
84
- @base_bootstrap_options_for = {}
85
- end
86
-
87
- attr_reader :compute_options
88
- attr_reader :aws_credentials
89
- attr_reader :openstack_credentials
90
- attr_reader :key_pairs
91
-
92
- def current_base_bootstrap_options
93
- result = @base_bootstrap_options.dup
94
- if key_pairs.size > 0
95
- last_pair_name = key_pairs.keys.last
96
- last_pair = key_pairs[last_pair_name]
97
- result[:key_name] ||= last_pair_name
98
- result[:private_key_path] ||= last_pair.private_key_path
99
- result[:public_key_path] ||= last_pair.public_key_path
100
- end
101
- result
102
- end
103
-
104
- # Inflate a provisioner from node information; we don't want to force the
105
- # driver to figure out what the provisioner really needs, since it varies
106
- # from provisioner to provisioner.
107
- #
108
- # ## Parameters
109
- # node - node to inflate the provisioner for
110
- #
111
- # returns a FogProvisioner
112
- # TODO: def self.inflate(node)
113
- # right now, not implemented, will raise error from base class until overridden
114
-
115
- # Acquire a machine, generally by provisioning it. Returns a Machine
116
- # object pointing at the machine, allowing useful actions like setup,
117
- # converge, execute, file and directory. The Machine object will have a
118
- # "node" property which must be saved to the server (if it is any
119
- # different from the original node object).
120
- #
121
- # ## Parameters
122
- # action_handler - the action_handler object that is calling this method; this
123
- # is generally a action_handler, but could be anything that can support the
124
- # ChefMetal::ActionHandler interface (i.e., in the case of the test
125
- # kitchen metal driver for acquiring and destroying VMs; see the base
126
- # class for what needs providing).
127
- # node - node object (deserialized json) representing this machine. If
128
- # the node has a provisioner_options hash in it, these will be used
129
- # instead of options provided by the provisioner. TODO compare and
130
- # fail if different?
131
- # node will have node['normal']['provisioner_options'] in it with any options.
132
- # It is a hash with this format:
133
- #
134
- # -- provisioner_url: fog:<relevant_fog_options>
135
- # -- bootstrap_options: hash of options to pass to compute.servers.create
136
- # -- is_windows: true if windows. TODO detect this from ami?
137
- # -- create_timeout - the time to wait for the instance to boot to ssh (defaults to 600)
138
- # -- start_timeout - the time to wait for the instance to start (defaults to 600)
139
- # -- ssh_timeout - the time to wait for ssh to be available if the instance is detected as up (defaults to 20)
140
- #
141
- # Example bootstrap_options for ec2:
142
- # 'bootstrap_options' => {
143
- # 'image_id' =>'ami-311f2b45',
144
- # 'flavor_id' =>'t1.micro',
145
- # 'key_name' => 'key-pair-name'
146
- # }
147
- #
148
- # node['normal']['provisioner_output'] will be populated with information
149
- # about the created machine. For vagrant, it is a hash with this
150
- # format:
151
- #
152
- # -- provisioner_url: fog:<relevant_fog_options>
153
- # -- server_id: the ID of the server so it can be found again
154
- #
155
- def acquire_machine(action_handler, node)
156
- # Set up the modified node data
157
- creator = case compute_options[:provider]
158
- when 'AWS'
159
- aws_login_info[1]
160
- when 'OpenStack'
161
- compute_options[:openstack_username]
162
- end
163
-
164
- provisioner_output = node['normal']['provisioner_output'] || {
165
- 'provisioner_url' => provisioner_url,
166
- 'provisioner_version' => ChefMetalFog::VERSION,
167
- 'creator' => creator
168
- }
169
-
170
- if provisioner_output['provisioner_url'] != provisioner_url
171
- raise "Switching a machine's provider from #{provisioner_output['provisioner_url']} to #{provisioner_url} for is not currently supported! Use machine :destroy and then re-create the machine on the new provisioner."
172
- end
173
-
174
- node['normal']['provisioner_output'] = provisioner_output
175
-
176
- if provisioner_output['server_id']
177
-
178
- # If the server already exists, make sure it is up
179
-
180
- # TODO verify that the server info matches the specification (ami, etc.)\
181
- server = server_for(node)
182
- if !server
183
- Chef::Log.warn "Machine #{node['name']} (#{provisioner_output['server_id']} on #{provisioner_url}) is not associated with the ec2 account. Recreating ..."
184
- need_to_create = true
185
- elsif %w(terminated archive).include?(server.state) # Can't come back from that
186
- Chef::Log.warn "Machine #{node['name']} (#{server.id} on #{provisioner_url}) is terminated. Recreating ..."
187
- need_to_create = true
188
- else
189
- need_to_create = false
190
- if !server.ready?
191
- action_handler.perform_action "start machine #{node['name']} (#{server.id} on #{provisioner_url})" do
192
- server.start
193
- end
194
- action_handler.perform_action "wait for machine #{node['name']} (#{server.id} on #{provisioner_url}) to be ready" do
195
- wait_until_ready(server, option_for(node, :start_timeout))
196
- end
197
- else
198
- wait_until_ready(server, option_for(node, :ssh_timeout))
199
- end
200
- end
201
- else
202
- need_to_create = true
203
- end
204
-
205
- if need_to_create
206
- # If the server does not exist, create it
207
- bootstrap_options = bootstrap_options_for(action_handler.new_resource, node)
208
- bootstrap_options.merge(:name => action_handler.new_resource.name)
209
-
210
- start_time = Time.now
211
- timeout = option_for(node, :create_timeout)
212
-
213
- description = [ "create machine #{node['name']} on #{provisioner_url}" ]
214
- bootstrap_options.each_pair { |key,value| description << " #{key}: #{value.inspect}" }
215
- server = nil
216
- action_handler.perform_action description do
217
- server = compute.servers.create(bootstrap_options)
218
- provisioner_output['server_id'] = server.id
219
- # Save quickly in case something goes wrong
220
- save_node(action_handler, node, action_handler.new_resource.chef_server)
221
- end
222
-
223
- if server
224
- @@ip_pool_lock = Mutex.new
225
- # Re-retrieve the server in a more malleable form and wait for it to be ready
226
- server = compute.servers.get(server.id)
227
- if bootstrap_options[:floating_ip_pool]
228
- Chef::Log.info 'Attaching IP from pool'
229
- server.wait_for { ready? }
230
- action_handler.perform_action "attach floating IP from #{bootstrap_options[:floating_ip_pool]} pool" do
231
- attach_ip_from_pool(server, bootstrap_options[:floating_ip_pool])
232
- end
233
- elsif bootstrap_options[:floating_ip]
234
- Chef::Log.info 'Attaching given IP'
235
- server.wait_for { ready? }
236
- action_handler.perform_action "attach floating IP #{bootstrap_options[:floating_ip]}" do
237
- attach_ip(server, bootstrap_options[:allocation_id], bootstrap_options[:floating_ip])
238
- end
239
- end
240
- action_handler.perform_action "machine #{node['name']} created as #{server.id} on #{provisioner_url}" do
241
- end
242
- # Wait for the machine to come up and for ssh to start listening
243
- transport = nil
244
- _self = self
245
- action_handler.perform_action "wait for machine #{node['name']} to boot" do
246
- server.wait_for(timeout - (Time.now - start_time)) do
247
- if ready?
248
- transport ||= _self.transport_for(server)
249
- begin
250
- transport.execute('pwd')
251
- true
252
- rescue ChefMetal::Transport::SSH::InitialConnectTimeout, Errno::ECONNREFUSED, Net::SSH::Disconnect, Errno::EHOSTUNREACH
253
- false
254
- rescue
255
- true
256
- end
257
- else
258
- false
259
- end
260
- end
261
- end
262
-
263
- # If there is some other error, we just wait patiently for SSH
264
- begin
265
- server.wait_for(option_for(node, :ssh_timeout)) { transport.available? }
266
- rescue Fog::Errors::TimeoutError
267
- # Sometimes (on EC2) the machine comes up but gets stuck or has
268
- # some other problem. If this is the case, we restart the server
269
- # to unstick it. Reboot covers a multitude of sins.
270
- Chef::Log.warn "Machine #{node['name']} (#{server.id} on #{provisioner_url}) was started but SSH did not come up. Rebooting machine in an attempt to unstick it ..."
271
- action_handler.perform_action "reboot machine #{node['name']} to try to unstick it" do
272
- server.reboot
273
- end
274
- action_handler.perform_action "wait for machine #{node['name']} to be ready after reboot" do
275
- wait_until_ready(server, option_for(node, :start_timeout))
276
- end
277
- end
278
- end
279
- end
280
-
281
- # Create machine object for callers to use
282
- machine_for(node, server)
283
- end
284
-
285
- # Attach IP to machine from IP pool
286
- # Code taken from kitchen-openstack driver
287
- # https://github.com/test-kitchen/kitchen-openstack/blob/master/lib/kitchen/driver/openstack.rb#L196-L207
288
- def attach_ip_from_pool(server, pool)
289
- @@ip_pool_lock.synchronize do
290
- Chef::Log.info "Attaching floating IP from <#{pool}> pool"
291
- free_addrs = compute.addresses.collect do |i|
292
- i.ip if i.fixed_ip.nil? and i.instance_id.nil? and i.pool == pool
293
- end.compact
294
- if free_addrs.empty?
295
- raise ActionFailed, "No available IPs in pool <#{pool}>"
296
- end
297
- attach_ip(server, free_addrs[0])
298
- end
299
- end
300
-
301
- # Attach given IP to machine
302
- # Code taken from kitchen-openstack driver
303
- # https://github.com/test-kitchen/kitchen-openstack/blob/master/lib/kitchen/driver/openstack.rb#L209-L213
304
- def attach_ip(server, allocation_id, ip)
305
- Chef::Log.info "Attaching floating IP <#{ip}>"
306
- compute.associate_address(:instance_id => server.id,
307
- :allocation_id => allocation_id,
308
- :public_ip => ip)
309
- end
310
-
311
- # Connect to machine without acquiring it
312
- def connect_to_machine(node)
313
- machine_for(node)
314
- end
315
-
316
- def delete_machine(action_handler, node)
317
- if node['normal']['provisioner_output'] && node['normal']['provisioner_output']['server_id']
318
- server = compute.servers.get(node['normal']['provisioner_output']['server_id'])
319
- if server
320
- action_handler.perform_action "destroy machine #{node['name']} (#{node['normal']['provisioner_output']['server_id']} at #{provisioner_url})" do
321
- server.destroy
322
- end
323
- end
324
- convergence_strategy_for(node).cleanup_convergence(action_handler, node)
325
- end
326
- end
327
-
328
- def stop_machine(action_handler, node)
329
- # If the machine doesn't exist, we silently do nothing
330
- if node['normal']['provisioner_output'] && node['normal']['provisioner_output']['server_id']
331
- server = compute.servers.get(node['normal']['provisioner_output']['server_id'])
332
- action_handler.perform_action "stop machine #{node['name']} (#{server.id} at #{provisioner_url})" do
333
- server.stop
334
- end
335
- end
336
- end
337
-
338
- def resource_created(machine)
339
- @base_bootstrap_options_for[machine] = current_base_bootstrap_options
340
- end
341
-
342
- def compute
343
- @compute ||= Fog::Compute.new(compute_options)
344
- end
345
-
346
- def provisioner_url
347
- provider_identifier = case compute_options[:provider]
348
- when 'AWS'
349
- aws_login_info[0]
350
- when 'DigitalOcean'
351
- compute_options[:digitalocean_client_id]
352
- when 'OpenStack'
353
- compute_options[:openstack_auth_url]
354
- else
355
- '???'
356
- end
357
- "fog:#{compute_options[:provider]}:#{provider_identifier}"
358
- end
359
-
360
- # Not meant to be part of public interface
361
- def transport_for(server)
362
- # TODO winrm
363
- create_ssh_transport(server)
364
- end
365
-
366
- protected
367
-
368
- def option_for(node, key)
369
- if node['normal']['provisioner_options'] && node['normal']['provisioner_options'][key.to_s]
370
- node['normal']['provisioner_options'][key.to_s]
371
- elsif compute_options[key]
372
- compute_options[key]
373
- else
374
- DEFAULT_OPTIONS[key]
375
- end
376
- end
377
-
378
- # Returns [ Account ID, User ]
379
- # Account ID is the 12 digit identifier on your Manage Account page in AWS Console. It is used as part of all ARNs identifying resources.
380
- # User is an identifier like "root" or "user/username" or "federated-user/username"
381
- def aws_login_info
382
- @aws_login_info ||= begin
383
- iam = Fog::AWS::IAM.new(:aws_access_key_id => compute_options[:aws_access_key_id], :aws_secret_access_key => compute_options[:aws_secret_access_key])
384
- arn = begin
385
- # TODO it would be nice if Fog let you do this normally ...
386
- iam.send(:request, {
387
- 'Action' => 'GetUser',
388
- :parser => Fog::Parsers::AWS::IAM::GetUser.new
389
- }).body['User']['Arn']
390
- rescue Fog::AWS::IAM::Error
391
- # TODO Someone tell me there is a better way to find out your current
392
- # user ID than this! This is what happens when you use an IAM user
393
- # with default privileges.
394
- if $!.message =~ /AccessDenied.+(arn:aws:iam::\d+:\S+)/
395
- arn = $1
396
- else
397
- raise
398
- end
399
- end
400
- arn.split(':')[4..5]
401
- end
402
- end
403
-
404
- def symbolize_keys(options)
405
- options.inject({}) { |result,(key,value)| result[key.to_sym] = value; result }
406
- end
407
-
408
- def server_for(node)
409
- if node['normal']['provisioner_output'] && node['normal']['provisioner_output']['server_id']
410
- compute.servers.get(node['normal']['provisioner_output']['server_id'])
411
- else
412
- nil
413
- end
414
- end
415
-
416
- def bootstrap_options_for(machine, node)
417
- provisioner_options = node['normal']['provisioner_options'] || {}
418
- bootstrap_options = @base_bootstrap_options_for[machine] || current_base_bootstrap_options
419
- bootstrap_options = bootstrap_options.merge(symbolize_keys(provisioner_options['bootstrap_options'] || {}))
420
- require 'socket'
421
- require 'etc'
422
- tags = {
423
- 'Name' => node['name'],
424
- 'BootstrapChefServer' => machine.chef_server[:chef_server_url],
425
- 'BootstrapHost' => Socket.gethostname,
426
- 'BootstrapUser' => Etc.getlogin,
427
- 'BootstrapNodeName' => node['name']
428
- }
429
- if machine.chef_server[:options] && machine.chef_server[:options][:data_store]
430
- tags['ChefLocalRepository'] = machine.chef_server[:options][:data_store].chef_fs.fs_description
431
- end
432
- # User-defined tags override the ones we set
433
- tags.merge!(bootstrap_options[:tags]) if bootstrap_options[:tags]
434
- bootstrap_options.merge!({ :tags => tags })
435
-
436
- # Provide reasonable defaults for DigitalOcean
437
- if compute_options[:provider] == 'DigitalOcean'
438
- if !bootstrap_options[:image_id]
439
- bootstrap_options[:image_name] ||= 'CentOS 6.4 x32'
440
- bootstrap_options[:image_id] = compute.images.select { |image| image.name == bootstrap_options[:image_name] }.first.id
441
- end
442
- if !bootstrap_options[:flavor_id]
443
- bootstrap_options[:flavor_name] ||= '512MB'
444
- bootstrap_options[:flavor_id] = compute.flavors.select { |flavor| flavor.name == bootstrap_options[:flavor_name] }.first.id
445
- end
446
- if !bootstrap_options[:region_id]
447
- bootstrap_options[:region_name] ||= 'San Francisco 1'
448
- bootstrap_options[:region_id] = compute.regions.select { |region| region.name == bootstrap_options[:region_name] }.first.id
449
- end
450
- bootstrap_options[:ssh_key_ids] ||= [ compute.ssh_keys.select { |k| k.name == bootstrap_options[:key_name] }.first.id ]
451
-
452
- # You don't get to specify name yourself
453
- bootstrap_options[:name] = node['name']
454
- end
455
-
456
- bootstrap_options
457
- end
458
-
459
- def machine_for(node, server = nil)
460
- server ||= server_for(node)
461
- if !server
462
- raise "Server for node #{node['name']} has not been created!"
463
- end
464
-
465
- if node['normal']['provisioner_options'] && node['normal']['provisioner_options']['is_windows']
466
- ChefMetal::Machine::WindowsMachine.new(node, transport_for(server), convergence_strategy_for(node))
467
- else
468
- ChefMetal::Machine::UnixMachine.new(node, transport_for(server), convergence_strategy_for(node))
469
- end
470
- end
471
-
472
- def convergence_strategy_for(node)
473
- if node['normal']['provisioner_options'] && node['normal']['provisioner_options']['is_windows']
474
- @windows_convergence_strategy ||= begin
475
- options = {}
476
- provisioner_options = node['normal']['provisioner_options'] || {}
477
- options[:chef_client_timeout] = provisioner_options['chef_client_timeout'] if provisioner_options.has_key?('chef_client_timeout')
478
- ChefMetal::ConvergenceStrategy::InstallMsi.new(options)
479
- end
480
- else
481
- @unix_convergence_strategy ||= begin
482
- options = {}
483
- provisioner_options = node['normal']['provisioner_options'] || {}
484
- options[:chef_client_timeout] = provisioner_options['chef_client_timeout'] if provisioner_options.has_key?('chef_client_timeout')
485
- ChefMetal::ConvergenceStrategy::InstallCached.new(options)
486
- end
487
- end
488
- end
489
-
490
- def ssh_options_for(server)
491
- result = {
492
- # TODO create a user known hosts file
493
- # :user_known_hosts_file => vagrant_ssh_config['UserKnownHostsFile'],
494
- # :paranoid => true,
495
- :auth_methods => [ 'publickey' ],
496
- :keys_only => true,
497
- :host_key_alias => "#{server.id}.#{compute_options[:provider]}"
498
- }
499
- if server.respond_to?(:private_key) && server.private_key
500
- result[:key_data] = [ server.private_key ]
501
- elsif server.respond_to?(:key_name) && key_pairs[server.key_name]
502
- # TODO generalize for others?
503
- result[:keys] ||= [ key_pairs[server.key_name].private_key_path ]
504
- else
505
- # TODO need a way to know which key if there were multiple
506
- result[:keys] = [ key_pairs.first[1].private_key_path ]
507
- end
508
- result
509
- end
510
-
511
- def create_ssh_transport(server)
512
- ssh_options = ssh_options_for(server)
513
- # If we're on AWS, the default is to use ubuntu, not root
514
- if compute_options[:provider] == 'AWS'
515
- username = compute_options[:ssh_username] || 'ubuntu'
516
- else
517
- username = compute_options[:ssh_username] || 'root'
518
- end
519
- options = {}
520
- if compute_options[:sudo] || (!compute_options.has_key?(:sudo) && username != 'root')
521
- options[:prefix] = 'sudo '
522
- end
523
-
524
- remote_host = nil
525
- if compute_options[:use_private_ip_for_ssh]
526
- remote_host = server.private_ip_address
527
- elsif !server.public_ip_address
528
- Chef::Log.warn("Server has no public ip address. Using private ip '#{server.private_ip_address}'. Set provisioner option 'use_private_ip_for_ssh' => true if this will always be the case ...")
529
- remote_host = server.private_ip_address
530
- elsif server.public_ip_address
531
- remote_host = server.public_ip_address
532
- else
533
- raise "Server #{server.id} has no private or public IP address!"
534
- end
535
-
536
- #Enable pty by default
537
- options[:ssh_pty_enable] = true
538
-
539
- ChefMetal::Transport::SSH.new(remote_host, username, ssh_options, options)
540
- end
541
-
542
- def wait_until_ready(server, timeout)
543
- transport = nil
544
- _self = self
545
- server.wait_for(timeout) do
546
- if transport
547
- transport.available?
548
- elsif ready?
549
- # Don't create the transport until the machine is ready (we won't have the host till then)
550
- transport = _self.transport_for(server)
551
- transport.available?
552
- else
553
- false
554
- end
555
- end
556
- end
557
- end
558
- end