beaker-openstack 2.0.0 → 3.0.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.
@@ -1,451 +1,445 @@
1
1
  module Beaker
2
- #Beaker support for OpenStack
3
- #This code is EXPERIMENTAL!
4
- #Please file any issues/concerns at https://github.com/puppetlabs/beaker/issues
2
+ # Beaker support for OpenStack
3
+ # Please file any issues/concerns at https://github.com/voxpupuli/beaker-openstack/issues
4
+
5
+ # Additional volumes created via openstack_volume_support are preserved and not deleted by cleanup
5
6
  class Openstack < Beaker::Hypervisor
6
7
 
7
- SLEEPWAIT = 5
8
-
9
- #Create a new instance of the OpenStack hypervisor object
10
- #@param [<Host>] openstack_hosts The array of OpenStack hosts to provision
11
- #@param [Hash{Symbol=>String}] options The options hash containing configuration values
12
- #@option options [String] :openstack_api_key The key to access the OpenStack instance with (required)
13
- #@option options [String] :openstack_username The username to access the OpenStack instance with (required)
14
- #@option options [String] :openstack_auth_url The URL to access the OpenStack instance with (required)
15
- #@option options [String] :openstack_tenant The tenant to access the OpenStack instance with (either this or openstack_project_name is required)
16
- #@option options [String] :openstack_project_name The project name to access the OpenStack instance with (either this or openstack_tenant is required)
17
- #@option options [String] :openstack_project_id The project id to access the OpenStack instance with (alternative to openstack_project_name)
18
- #@option options [String] :openstack_user_domain The user domain name to access the OpenStack instance with
19
- #@option options [String] :openstack_user_domain_id The user domain id to access the OpenStack instance with (alternative to openstack_user_domain)
20
- #@option options [String] :openstack_project_domain The project domain to access the OpenStack instance with
21
- #@option options [String] :openstack_project_domain_id The project domain id to access the OpenStack instance with (alternative to openstack_project_domain)
22
- #@option options [String] :openstack_region The region that each OpenStack instance should be provisioned on (optional)
23
- #@option options [String] :openstack_network The network that each OpenStack instance should be contacted through (required)
24
- #@option options [Bool] :openstack_floating_ip Whether a floating IP should be allocated (required)
25
- #@option options [String] :openstack_keyname The name of an existing key pair that should be auto-loaded onto each
26
- #@option options [Hash] :security_group An array of security groups to associate with the instance
27
- # OpenStack instance (optional)
28
- #@option options [String] :jenkins_build_url Added as metadata to each OpenStack instance
29
- #@option options [String] :department Added as metadata to each OpenStack instance
30
- #@option options [String] :project Added as metadata to each OpenStack instance
31
- #@option options [Integer] :timeout The amount of time to attempt execution before quiting and exiting with failure
8
+ SLEEPWAIT = 5 # Seconds to wait between retry attempts for VM boot
9
+
10
+ # Create a new OpenStack hypervisor object
11
+ #
12
+ # @param [Array<Host>] openstack_hosts Array of hosts to provision
13
+ # @param [Hash{Symbol=>Object}] options Configuration options:
14
+ # @option options [String] :openstack_api_key Required API key
15
+ # @option options [String] :openstack_username Required username
16
+ # @option options [String] :openstack_auth_url Required auth URL
17
+ # @option options [String] :openstack_project_name Project name (v3) or nil
18
+ # @option options [String] :openstack_project_id Project ID (v3) or nil
19
+ # @option options [String] :openstack_user_domain Optional user domain for v3
20
+ # @option options [String] :openstack_user_domain_id Optional user domain ID for v3
21
+ # @option options [String] :openstack_project_domain Optional project domain for v3
22
+ # @option options [String] :openstack_project_domain_id Optional project domain ID for v3
23
+ # @option options [String] :openstack_region The region that each OpenStack instance should be provisioned on (optional)
24
+ # @option options [String] :openstack_network Required network for VM
25
+ # @option options [Bool] :openstack_floating_ip Whether to assign a floating IP
26
+ # @option options [String] :floating_ip_pool Required floating network ID when floating IPs are enabled
27
+ # @option options [Bool] :openstack_volume_support Whether to provision additional volumes
28
+ # @option options [String] :openstack_keyname Optional pre-existing keypair name
29
+ # @option options [Array<String>] :security_group Optional security groups
30
+ # @option options [Integer] :timeout Timeout in seconds for VM boot
31
+ # @option options [String] :jenkins_build_url Optional metadata
32
+ # @option options [String] :department Optional metadata
33
+ # @option options [String] :project Optional metadata
32
34
  def initialize(openstack_hosts, options)
33
35
  require 'fog/openstack'
36
+
34
37
  @options = options
35
- @logger = options[:logger]
36
- @hosts = openstack_hosts
38
+ @logger = options[:logger]
39
+ @hosts = openstack_hosts
40
+
41
+ # Initialize shared resources and mutexes for thread safety
37
42
  @vms = []
43
+ @floating_ips = []
44
+ @vms_mutex = Mutex.new
45
+ @fip_mutex = Mutex.new
46
+ @keypairs_mutex = Mutex.new
47
+ @cleanup_mutex = Mutex.new
48
+ @cleanup_ran = false
49
+
50
+ # Track keys we create so we only delete what we own
51
+ @ephemeral_keypairs = []
52
+
53
+ # Required options
54
+ raise 'You must specify :openstack_api_key' unless @options[:openstack_api_key]
55
+ raise 'You must specify :openstack_username' unless @options[:openstack_username]
56
+ raise 'You must specify :openstack_auth_url' unless @options[:openstack_auth_url]
57
+ raise 'You must specify :openstack_network' unless @options[:openstack_network]
58
+ raise 'You must specify :openstack_floating_ip (true/false)' if @options[:openstack_floating_ip].nil?
59
+
60
+ # Floating IP pool is required only if floating IPs are enabled
61
+ raise 'You must specify :floating_ip_pool when using floating IPs' if @options[:openstack_floating_ip] && !@options[:floating_ip_pool]
62
+
63
+ # Keystone version detection
64
+ # Matches both /v3 and /v3/ endings to avoid false negatives
65
+ is_v3 = @options[:openstack_auth_url].match?(%r{/v3/?$})
66
+
67
+ # Enforce Keystone v3 only (v2 is no longer supported)
68
+ raise 'Keystone v2 is no longer supported. Please use a /v3 auth URL.' unless is_v3
69
+
70
+ # project_name or project_id required
71
+ raise 'Specify project_name or project_id' unless @options[:openstack_project_name] || @options[:openstack_project_id]
72
+
73
+ # cannot specify both
74
+ raise 'Do not mix project_name and project_id' if @options[:openstack_project_name] && @options[:openstack_project_id]
75
+
76
+ # user_domain XOR user_domain_id
77
+ raise 'Specify either :openstack_user_domain or :openstack_user_domain_id, not both' if @options[:openstack_user_domain] && @options[:openstack_user_domain_id]
78
+
79
+ # project_domain XOR project_domain_id
80
+ raise 'Specify either :openstack_project_domain or :openstack_project_domain_id, not both' if @options[:openstack_project_domain] && @options[:openstack_project_domain_id]
81
+
82
+ # fog-openstack limitation: do not mix _id and non-_id fields
83
+ if (@options[:openstack_project_name] ||
84
+ @options[:openstack_user_domain] ||
85
+ @options[:openstack_project_domain]) &&
86
+ (@options[:openstack_project_id] ||
87
+ @options[:openstack_user_domain_id] ||
88
+ @options[:openstack_project_domain_id])
89
+ raise 'Do not mix _id and non-_id values for project/user domains due to fog-openstack limitations'
90
+ end
38
91
 
39
- raise 'You must specify an Openstack API key (:openstack_api_key) for OpenStack instances!' unless @options[:openstack_api_key]
40
- raise 'You must specify an Openstack username (:openstack_username) for OpenStack instances!' unless @options[:openstack_username]
41
- raise 'You must specify an Openstack auth URL (:openstack_auth_url) for OpenStack instances!' unless @options[:openstack_auth_url]
42
- raise 'You must specify an Openstack network (:openstack_network) for OpenStack instances!' unless @options[:openstack_network]
43
- raise 'You must specify whether a floating IP (:openstack_floating_ip) should be used for OpenStack instances!' unless !@options[:openstack_floating_ip].nil?
44
-
45
- is_v3 = @options[:openstack_auth_url].include?('/v3/')
46
- raise 'You must specify an Openstack tenant (:openstack_tenant) for OpenStack instances!' if !is_v3 and !@options[:openstack_tenant]
47
- raise 'You must specify an Openstack project name (:openstack_project_name) or Openstack project id (:openstack_project_id) for OpenStack instances!' if is_v3 and (!@options[:openstack_project_name] and !@options[:openstack_project_id])
48
- raise 'You must specify either Openstack project name (:openstack_project_name) or Openstack project id (:openstack_project_id) not both!' if is_v3 and (@options[:openstack_project_name] and @options[:openstack_project_id])
49
- raise 'You may specify either Openstack user domain (:openstack_user_domain) or Openstack user domain id (:openstack_user_domain_id) not both!' if is_v3 and (@options[:openstack_user_domain] and @options[:openstack_user_domain_id])
50
- raise 'You may specify either Openstack project domain (:openstack_project_domain) or Openstack project domain id (:openstack_project_domain_id) not both!' if is_v3 and (@options[:openstack_project_domain] and @options[:openstack_project_domain_id])
51
- raise 'Invalid option specified: v3 API expects :openstack_project_name or :openstack_project_id, not :openstack_tenant for OpenStack instances!' if is_v3 and @options[:openstack_tenant]
52
- raise 'Invalid option specified: v2 API expects :openstack_tenant, not :openstack_project_name or :openstack_project_id for OpenStack instances!' if !is_v3 and (@options[:openstack_project_name] or @options[:openstack_project_id])
53
- # Ensure that _id and non _id params are not mixed (due to bug in fog-openstack)
54
- raise 'You must not mix _id values non _id (name) values. Please use the same type for (:openstack_project_), (:openstack_user_domain) and (:openstack_project_domain)!' if is_v3 and (@options[:openstack_project_name] or @options[:openstack_user_domain] or @options[:openstack_project_domain]) and (@options[:openstack_project_id] or @options[:openstack_user_domain_id] or @options[:openstack_project_domain_id])
55
-
56
- # Keystone version 3 changed the parameter names
57
- if !is_v3
58
- extra_credentials = {:openstack_tenant => @options[:openstack_tenant]}
59
- else
92
+ # Build credential scope depending on Keystone version
93
+ extra_credentials =
60
94
  if @options[:openstack_project_id]
61
- extra_credentials = {:openstack_project_id => @options[:openstack_project_id]}
95
+ { openstack_project_id: @options[:openstack_project_id] }
62
96
  else
63
- extra_credentials = {:openstack_project_name => @options[:openstack_project_name]}
97
+ { openstack_project_name: @options[:openstack_project_name] }
64
98
  end
65
- end
66
99
 
67
- # Common keystone authentication credentials
100
+ # Base credentials (no duplicate tenant/project fields)
68
101
  @credentials = {
69
- :provider => :openstack,
70
- :openstack_auth_url => @options[:openstack_auth_url],
71
- :openstack_api_key => @options[:openstack_api_key],
72
- :openstack_username => @options[:openstack_username],
73
- :openstack_tenant => @options[:openstack_tenant],
74
- :openstack_region => @options[:openstack_region],
102
+ provider: :openstack,
103
+ openstack_auth_url: @options[:openstack_auth_url],
104
+ openstack_api_key: @options[:openstack_api_key],
105
+ openstack_username: @options[:openstack_username],
106
+ openstack_region: @options[:openstack_region]
75
107
  }.merge(extra_credentials)
76
108
 
77
- # Keystone version 3 requires users and projects to be scoped
78
- if is_v3
79
- if @options[:openstack_user_domain_id]
80
- @credentials[:openstack_user_domain_id] = @options[:openstack_user_domain_id]
81
- else
82
- @credentials[:openstack_user_domain] = @options[:openstack_user_domain] || 'Default'
83
- end
84
- if @options[:openstack_project_domain_id]
85
- @credentials[:openstack_project_domain_id] = @options[:openstack_project_domain_id]
86
- else
87
- @credentials[:openstack_project_domain] = @options[:openstack_project_domain] || 'Default'
88
- end
89
- end
109
+ # Keystone v3 domain scoping
110
+ @credentials[:openstack_user_domain_id] = @options[:openstack_user_domain_id] if @options[:openstack_user_domain_id]
111
+ @credentials[:openstack_user_domain] ||= @options[:openstack_user_domain] || 'Default'
90
112
 
91
- @compute_client ||= Fog::Compute.new(@credentials)
113
+ @credentials[:openstack_project_domain_id] = @options[:openstack_project_domain_id] if @options[:openstack_project_domain_id]
114
+ @credentials[:openstack_project_domain] ||= @options[:openstack_project_domain] || 'Default'
92
115
 
93
- if not @compute_client
94
- raise "Unable to create OpenStack Compute instance (api key: #{@options[:openstack_api_key]}, username: #{@options[:openstack_username]}, auth_url: #{@options[:openstack_auth_url]}, tenant: #{@options[:openstack_tenant]}, project_name: #{@options[:openstack_project_name]})"
95
- end
116
+ # Create clients
117
+ # These are created once during initialization (no memoization needed)
118
+ @compute_client = Fog::Compute.new(@credentials)
119
+ raise "Unable to create OpenStack Compute instance" unless @compute_client
96
120
 
97
- @network_client ||= Fog::Network.new(@credentials)
98
-
99
- if not @network_client
100
- raise "Unable to create OpenStack Network instance (api key: #{@options[:openstack_api_key]}, username: #{@options[:openstack_username]}, auth_url: #{@options[:openstack_auth_url]}, tenant: #{@options[:openstack_tenant]}, project_name: #{@options[:openstack_project_name]})"
101
- end
121
+ @network_client = Fog::Network.new(@credentials)
122
+ raise "Unable to create OpenStack Network instance" unless @network_client
102
123
 
103
- # Validate openstack_volume_support setting value, reset to boolean if passed via ENV value string
104
- @options[:openstack_volume_support] = true if @options[:openstack_volume_support].to_s.match(/\btrue\b/i)
105
- @options[:openstack_volume_support] = false if @options[:openstack_volume_support].to_s.match(/\bfalse\b/i)
106
- [true,false].include? @options[:openstack_volume_support] or raise "Invalid openstack_volume_support setting, current: @options[:openstack_volume_support]"
124
+ # --- openstack_volume_support normalization ---
125
+ # Accepts string or boolean input, but only true/false are valid outcomes
126
+ val = @options[:openstack_volume_support].to_s.downcase
127
+ @options[:openstack_volume_support] = true if val == "true"
128
+ @options[:openstack_volume_support] = false if val == "false"
107
129
 
130
+ raise "Invalid openstack_volume_support setting" unless [true, false].include?(@options[:openstack_volume_support])
108
131
  end
109
132
 
110
- #Provided a flavor name return the OpenStack id for that flavor
111
- #@param [String] f The flavor name
112
- #@return [String] Openstack id for provided flavor name
113
- def flavor f
114
- @logger.debug "OpenStack: Looking up flavor '#{f}'"
115
- @compute_client.flavors.find { |x| x.name == f } || raise("Couldn't find flavor: #{f}")
116
- end
133
+ # @!group Provision Methods
134
+ # Main provisioning entrypoint
135
+ def provision
136
+ if @options[:create_in_parallel]
137
+ Thread.abort_on_exception = true
138
+ @logger.notify "Provisioning OpenStack in parallel"
139
+ provision_parallel
140
+ else
141
+ @logger.notify "Provisioning OpenStack sequentially"
142
+ provision_sequential
143
+ end
117
144
 
118
- #Provided an image name return the OpenStack id for that image
119
- #@param [String] i The image name
120
- #@return [String] Openstack id for provided image name
121
- def image i
122
- @logger.debug "OpenStack: Looking up image '#{i}'"
123
- @compute_client.images.find { |x| x.name == i } || raise("Couldn't find image: #{i}")
145
+ hack_etc_hosts @hosts, @options
124
146
  end
125
147
 
126
- #Provided a network name return the OpenStack id for that network
127
- #@param [String] n The network name
128
- #@return [String] Openstack id for provided network name
129
- def network n
130
- @logger.debug "OpenStack: Looking up network '#{n}'"
131
- @network_client.networks.find { |x| x.name == n } || raise("Couldn't find network: #{n}")
148
+ def provision_parallel
149
+ threads = @hosts.map { |host| Thread.new { create_instance_resources(host) } }
150
+ threads.each(&:join)
132
151
  end
133
152
 
134
- #Provided an array of security groups return that array if all
135
- #security groups are present
136
- #@param [Array] sgs The array of security group names
137
- #@return [Array] The array of security group names
138
- def security_groups sgs
139
- for sg in sgs
140
- @logger.debug "Openstack: Looking up security group '#{sg}'"
141
- @compute_client.security_groups.find { |x| x.name == sg } || raise("Couldn't find security group: #{sg}")
142
- sgs
143
- end
153
+ def provision_sequential
154
+ @hosts.each { |host| create_instance_resources(host) }
144
155
  end
145
156
 
146
- # Create a volume client on request
147
- # @return [Fog::OpenStack::Volume] OpenStack volume client
148
- def volume_client_create
149
- @volume_client ||= Fog::Volume.new(@credentials)
150
- unless @volume_client
151
- raise "Unable to create OpenStack Volume instance"\
152
- " (api_key: #{@options[:openstack_api_key]},"\
153
- " username: #{@options[:openstack_username]},"\
154
- " auth_url: #{@options[:openstack_auth_url]},"\
155
- " tenant: #{@options[:openstack_tenant]})"
157
+ # Create all resources required for a single VM
158
+ def create_instance_resources(host)
159
+ @logger.notify "Provisioning #{host.name}"
160
+
161
+ # Revert to standard Beaker managed style (10 character random string) for VM hostname
162
+ host[:vmhostname] = ('a'..'z').to_a.sample(10).join
163
+
164
+ floating_ip = nil
165
+ if @options[:openstack_floating_ip]
166
+ floating_ip = get_floating_ip
167
+ # Track the Floating IP object immediately for cleanup safety
168
+ @fip_mutex.synchronize { @floating_ips << floating_ip }
169
+
170
+ # Capture the actual IP string for connectivity
171
+ actual_ip = floating_ip.respond_to?(:floating_ip_address) ? floating_ip.floating_ip_address : (floating_ip.respond_to?(:ip) ? floating_ip.ip : floating_ip.address)
172
+ host[:ip] = actual_ip
156
173
  end
157
- end
158
174
 
159
- # Get a hash of volumes from the host
160
- def get_volumes host
161
- return host['volumes'] if host['volumes']
162
- {}
163
- end
175
+ create_or_associate_keypair(host, host[:vmhostname])
176
+
177
+ server_opts = {
178
+ name: host[:vmhostname],
179
+ flavor_ref: flavor(host[:flavor]).id,
180
+ nics: [{ 'net_id' => network(@options[:openstack_network]).id }],
181
+ key_name: host[:keyname],
182
+ security_groups: @options[:security_group] ? security_groups(@options[:security_group]) : nil,
183
+ user_data: host[:user_data] || "#cloud-config\nmanage_etc_hosts: true\n"
184
+ }
164
185
 
165
- # Get the API version
166
- def get_volume_api_version
167
- case @volume_client
168
- when Fog::Volume::OpenStack::V1
169
- 1
186
+ if boot_from_volume?(host)
187
+ server_opts[:block_device_mapping_v2] = [{
188
+ uuid: image(host[:image]).id,
189
+ source_type: "image",
190
+ destination_type: "volume",
191
+ volume_size: host['root_volume']['size'].to_i,
192
+ delete_on_termination: host['root_volume'].key?('delete_on_termination') ? !!host['root_volume']['delete_on_termination'] : true,
193
+ boot_index: 0
194
+ }]
170
195
  else
171
- -1
196
+ server_opts[:image_ref] = image(host[:image]).id
172
197
  end
173
- end
174
198
 
175
- # Create and attach dynamic volumes
176
- #
177
- # Creates an array of volumes and attaches them to the current host.
178
- # The host bus type is determined by the image type, so by default
179
- # devices appear as /dev/vdb, /dev/vdc etc. Setting the glance
180
- # properties hw_disk_bus=scsi, hw_scsi_model=virtio-scsi will present
181
- # them as /dev/sdb, /dev/sdc (or 2:0:0:1, 2:0:0:2 in SCSI addresses)
182
- #
183
- # @param host [Hash] thet current host defined in the nodeset
184
- # @param vm [Fog::Compute::OpenStack::Server] the server to attach to
185
- def provision_storage host, vm
186
- volumes = get_volumes(host)
187
- if !volumes.empty?
188
- # Lazily create the volume client if needed
189
- volume_client_create
190
- volumes.keys.each_with_index do |volume, index|
191
- @logger.debug "Creating volume #{volume} for OpenStack host #{host.name}"
192
-
193
- # The node defintion file defines volume sizes in MB (due to precedent
194
- # with the vagrant virtualbox implementation) however OpenStack requires
195
- # this translating into GB
196
- openstack_size = volumes[volume]['size'].to_i / 1000
197
-
198
- # Set up the volume creation arguments
199
- args = {
200
- :size => openstack_size,
201
- :description => "Beaker volume: host=#{host.name} volume=#{volume}",
202
- }
203
-
204
- # Between version 1 and subsequent versions the API was updated to
205
- # rename 'display_name' to just 'name' for better consistency
206
- if get_volume_api_version == 1
207
- args[:display_name] = volume
208
- else
209
- args[:name] = volume
210
- end
199
+ vm = @compute_client.servers.create(server_opts)
200
+ vm.wait_for(@options[:timeout] || 600) { respond_to?(:ready?) ? ready? : state == 'ACTIVE' }
211
201
 
212
- # Create the volume and wait for it to become available
213
- vol = @volume_client.volumes.create(**args)
214
- vol.wait_for { ready? }
202
+ # Register VM for cleanup
203
+ @vms_mutex.synchronize { @vms << vm }
215
204
 
216
- # Fog needs a device name to attach as, so invent one. The guest
217
- # doesn't pay any attention to this
218
- device = "/dev/vd#{('b'.ord + index).chr}"
219
- vm.attach_volume(vol.id, device)
205
+ if @options[:openstack_floating_ip] && floating_ip
206
+ # Associate the IP via Compute client to ensure compatibility with Neutron objects
207
+ @compute_client.associate_address(vm.id, host[:ip])
208
+ else
209
+ # Prefer IPv4 address if multiple networks exist
210
+ addr = vm.addresses.values.flatten.find { |a| a['version'] == 4 }
211
+ host[:ip] = addr && addr['addr']
212
+
213
+ if host[:ip].nil?
214
+ @logger.warn "[#{host.name}] No IPv4 address found; VM may be IPv6-only"
220
215
  end
221
216
  end
222
- end
223
217
 
224
- # Detach and delete guest volumes
225
- # @param vm [Fog::Compute::OpenStack::Server] the server to detach from
226
- def cleanup_storage vm
227
- vm.volumes.each do |vol|
228
- @logger.debug "Deleting volume #{vol.name} for OpenStack host #{vm.name}"
229
- vm.detach_volume(vol.id)
230
- vol.wait_for { ready? }
231
- vol.destroy
232
- end
233
- end
218
+ @logger.debug "[#{host.name}] Assigned IP #{host[:ip]}"
234
219
 
235
- # Get a floating IP address to associate with the instance, try
236
- # to allocate a new one from the specified pool if none are available
237
- #
238
- # TODO(GiedriusS): convert to use @network_client. This API will be turned off
239
- # completely very soon.
240
- def get_floating_ip
220
+ # Metadata is best-effort (some clouds disable it)
241
221
  begin
242
- @logger.debug "Creating IP"
243
- ip = @compute_client.addresses.create
244
- rescue Fog::OpenStack::Compute::NotFound
245
- # If there are no more floating IP addresses, allocate a
246
- # new one and try again.
247
- @compute_client.allocate_address(@options[:floating_ip_pool])
248
- ip = @compute_client.addresses.find { |ip| ip.instance_id.nil? }
222
+ vm.metadata.update(
223
+ jenkins_build_url: @options[:jenkins_build_url].to_s,
224
+ department: @options[:department].to_s,
225
+ project: @options[:project].to_s
226
+ )
227
+ rescue => e
228
+ @logger.debug("[#{host.name}] Metadata update failed: #{e.message}")
249
229
  end
250
- ip
251
- end
252
230
 
253
- # Create new instances in OpenStack, depending on if create_in_parallel is true or not
254
- def provision
255
- if @options[:create_in_parallel]
256
- # Enable abort on exception for threads
257
- Thread.abort_on_exception = true
258
- @logger.notify "Provisioning OpenStack in parallel"
259
- provision_parallel
260
- else
261
- @logger.notify "Provisioning OpenStack sequentially"
262
- provision_sequential
263
- end
264
- hack_etc_hosts @hosts, @options
265
- end
231
+ host.wait_for_port(22)
232
+ enable_root(host)
266
233
 
267
- # Parallel creation wrapper
268
- def provision_parallel
269
- # Array to store threads
270
- threads = @hosts.map do |host|
271
- Thread.new do
272
- create_instance_resources(host)
234
+ provision_storage(host, vm) if @options[:openstack_volume_support]
235
+
236
+ rescue => e
237
+ @logger.error "Provision failed: #{e.message}"
238
+
239
+ @cleanup_mutex.synchronize do
240
+ unless @cleanup_ran
241
+ @cleanup_ran = true
242
+ cleanup
273
243
  end
274
244
  end
275
- # Wait for all threads to finish
276
- threads.each(&:join)
277
- end
278
245
 
279
- # Sequential creation wrapper
280
- def provision_sequential
281
- @hosts.each do |host|
282
- create_instance_resources(host)
283
- end
246
+ raise e
284
247
  end
285
248
 
286
- # Create the actual instance resources
287
- def create_instance_resources(host)
288
- @logger.notify "Provisioning OpenStack"
289
- if @options[:openstack_floating_ip]
290
- ip = get_floating_ip
291
- hostname = ip.ip.gsub('.', '-')
292
- host[:vmhostname] = hostname + '.rfc1918.puppetlabs.net'
249
+ # Get key_name from options or generate a new RSA key and add it to OpenStack keypairs
250
+ def create_or_associate_keypair(host, keyname)
251
+ if @options[:openstack_keyname]
252
+ host[:keyname] = @options[:openstack_keyname]
253
+ @logger.debug "Using existing keypair #{@options[:openstack_keyname]}"
293
254
  else
294
- hostname = ('a'..'z').to_a.shuffle[0, 10].join
295
- host[:vmhostname] = hostname
255
+ # Remove any existing ephemeral key with this name to avoid collisions
256
+ @compute_client.key_pairs.get(keyname)&.destroy
257
+
258
+ # Generate new RSA keypair
259
+ key = OpenSSL::PKey::RSA.new(2048)
260
+ type = key.ssh_type
261
+ data = [key.to_blob].pack('m0')
262
+ @compute_client.create_key_pair keyname, "#{type} #{data}"
263
+
264
+ # Track ephemeral keypairs for cleanup
265
+ @keypairs_mutex.synchronize { @ephemeral_keypairs << keyname }
266
+
267
+ # Inject private key into Beaker host
268
+ host['ssh'][:key_data] = [key.to_pem]
269
+ host[:keyname] = keyname
296
270
  end
271
+ end
297
272
 
298
- create_or_associate_keypair(host, hostname)
299
- @logger.debug "Provisioning #{host.name} (#{host[:vmhostname]})"
300
- options = {
301
- :flavor_ref => flavor(host[:flavor]).id,
302
- :image_ref => image(host[:image]).id,
303
- :nics => [{'net_id' => network(@options[:openstack_network]).id}],
304
- :name => host[:vmhostname],
305
- :hostname => host[:vmhostname],
306
- :user_data => host[:user_data] || "#cloud-config\nmanage_etc_hosts: true\n",
307
- :key_name => host[:keyname],
308
- }
309
- options[:security_groups] = security_groups(@options[:security_group]) unless @options[:security_group].nil?
310
- vm = @compute_client.servers.create(options)
273
+ # Get a floating IP address from the configured pool
274
+ # supports both network name and network UUID
275
+ def get_floating_ip
276
+ # Check if floating_ip_pool is a UUID; if not, look up the network ID by name
277
+ pool_id = if @options[:floating_ip_pool] =~ /^[0-9a-f-]{36}$/i
278
+ @options[:floating_ip_pool]
279
+ else
280
+ @logger.debug "Looking up floating IP pool network by name: #{@options[:floating_ip_pool]}"
281
+ network(@options[:floating_ip_pool]).id
282
+ end
283
+
284
+ @network_client.floating_ips.create(
285
+ floating_network_id: pool_id
286
+ )
287
+ end
311
288
 
312
- # Wait for the new instance to start up
313
- try = 1
314
- attempts = @options[:timeout].to_i / SLEEPWAIT
289
+ # Provision additional volumes (always preserved, never deleted automatically)
290
+ def provision_storage(host, vm)
291
+ return unless @options[:openstack_volume_support]
315
292
 
316
- while try <= attempts
293
+ volumes = get_volumes(host)
294
+ return if volumes.empty?
295
+
296
+ volume_client_create
297
+ device_index = 0
298
+
299
+ volumes.each do |vol_name, vol_def|
300
+ # Skip root volume if already handled via boot_from_volume
301
+ next if vol_name == 'root' && boot_from_volume?(host)
302
+
303
+ @logger.debug "Creating volume #{vol_name} for #{host.name}"
304
+
305
+ vol = @volume_client.volumes.create(
306
+ name: vol_name,
307
+ size: vol_def['size'].to_i,
308
+ description: vol_def['description'] || "Beaker volume: #{host.name}:#{vol_name}"
309
+ )
310
+
311
+ # Wait for Cinder to fully provision the volume
312
+ vol.wait_for(300) do
313
+ vol.reload
314
+ raise "Volume #{vol.name} entered error state: #{vol.status}" if vol.status =~ /error/i
315
+ vol.status == 'available'
316
+ end
317
+
318
+ # Device naming starts at /dev/vdc to avoid conflicts with root/ephemeral disks
319
+ device_letter = ('c'.ord + device_index)
320
+ raise "Too many volumes, cannot allocate device name" if device_letter > 'z'.ord
321
+ device = "/dev/vd#{device_letter.chr}"
322
+ device_index += 1
323
+
324
+ # Attach with retry (Nova attach sometimes races)
325
+ attempts = 0
317
326
  begin
318
- vm.wait_for(5) { ready? }
319
- break
320
- rescue Fog::Errors::TimeoutError => e
321
- if try >= attempts
322
- @logger.debug "Failed to connect to new OpenStack instance #{host.name} (#{host[:vmhostname]})"
323
- raise e
327
+ vm.attach_volume(vol.id, device)
328
+ rescue => e
329
+ attempts += 1
330
+ if attempts < 3
331
+ @logger.debug "Attach failed, retrying in 2s... (#{e.message})"
332
+ sleep 2
333
+ retry
324
334
  end
325
- @logger.debug "Timeout connecting to instance #{host.name} (#{host[:vmhostname]}), trying again..."
335
+ raise "Failed to attach volume #{vol_name} after 3 attempts: #{e}"
326
336
  end
327
- sleep SLEEPWAIT
328
- try += 1
329
- end
330
337
 
331
- if @options[:openstack_floating_ip]
332
- # Associate a public IP to the VM
333
- ip.server = vm
334
- host[:ip] = ip.ip
335
- else
336
- # Get the first address of the VM that was just created just like in the
337
- # OpenStack UI
338
- host[:ip] = vm.addresses.first[1][0]["addr"]
338
+ # Wait for Nova to complete attachment
339
+ vol.wait_for(120) do
340
+ vol.reload
341
+ vol.status == 'in-use'
342
+ end
339
343
  end
340
344
 
341
- @logger.debug "OpenStack host #{host.name} (#{host[:vmhostname]}) assigned ip: #{host[:ip]}"
342
-
343
- # Set metadata
344
- vm.metadata.update({:jenkins_build_url => @options[:jenkins_build_url].to_s,
345
- :department => @options[:department].to_s,
346
- :project => @options[:project].to_s })
347
- @vms << vm
348
-
349
- # Wait for the host to accept SSH logins
350
- host.wait_for_port(22)
351
-
352
- # Enable root if the user is not root
353
- enable_root(host)
354
-
355
- provision_storage(host, vm) if @options[:openstack_volume_support]
356
- @logger.notify "OpenStack Volume Support Disabled, can't provision volumes" if not @options[:openstack_volume_support]
345
+ # Ensure host['ssh'] hash exists for key injection if needed later
346
+ host['ssh'] ||= {}
347
+ end
357
348
 
358
- # Handle exceptions in the thread
359
- rescue => e
360
- @logger.error "Thread #{host} failed with error: #{e.message}"
361
- # Call cleanup function to delete orphaned hosts
362
- cleanup
363
- # Pass the error to the main thread to terminate all threads
364
- Thread.main.raise(e)
365
- # Terminate the current thread (to prevent hack_etc_hosts trying to run after error raised)
366
- Thread.kill(Thread.current)
349
+ # Enables root access for a single host when its current user is not 'root'
350
+ def enable_root(host)
351
+ return if host['user'] == 'root'
352
+ copy_ssh_to_root(host, @options)
353
+ enable_root_login(host, @options)
354
+ host['user'] = 'root'
355
+ host.close
367
356
  end
368
357
 
369
- # Destroy any OpenStack instances
358
+ # Cleanup all resources
359
+ # Ephemeral keypairs and VMs are destroyed; allocated Floating IPs are released; additional volumes are preserved
370
360
  def cleanup
371
361
  @logger.notify "Cleaning up OpenStack"
372
- @vms.each do |vm|
373
- cleanup_storage(vm) if @options[:openstack_volume_support]
374
- @logger.debug "Release floating IPs for OpenStack host #{vm.name}"
375
- floating_ips = vm.all_addresses # fetch and release its floating IPs
376
- floating_ips.each do |address|
377
- @compute_client.disassociate_address(vm.id, address['ip'])
378
- @compute_client.release_address(address['id'])
362
+
363
+ @vms_mutex.synchronize do
364
+ @vms.each do |vm|
365
+ begin
366
+ @logger.debug "Destroying #{vm.name}"
367
+ vm.destroy rescue nil
368
+ rescue => e
369
+ @logger.error "Cleanup error (VM): #{e.message}"
370
+ end
379
371
  end
380
- @logger.debug "Destroying OpenStack host #{vm.name}"
381
- vm.destroy
382
- if @options[:openstack_keyname].nil?
383
- @logger.debug "Deleting random keypair"
384
- @compute_client.delete_key_pair vm.key_name
372
+ @vms.clear
373
+ end
374
+
375
+ @fip_mutex.synchronize do
376
+ @floating_ips.each do |fip|
377
+ begin
378
+ ip_addr = fip.respond_to?(:floating_ip_address) ? fip.floating_ip_address : (fip.respond_to?(:ip) ? fip.ip : 'unknown')
379
+ @logger.debug "Releasing Floating IP #{ip_addr}"
380
+ fip.destroy rescue nil
381
+ rescue => e
382
+ @logger.error "Cleanup error (FIP): #{e.message}"
383
+ end
385
384
  end
385
+ @floating_ips.clear
386
386
  end
387
- end
388
387
 
389
- # Enables root access for a host when username is not root
390
- # This method ripped from the aws_sdk implementation and is probably wrong
391
- # because it iterates on a collection when there's no guarantee the collection
392
- # has all been brought up in openstack yet and will thus explode
393
- # @return [void]
394
- # @api private
395
- def enable_root_on_hosts
396
- @hosts.each do |host|
397
- enable_root(host)
388
+ @keypairs_mutex.synchronize do
389
+ @ephemeral_keypairs.each do |keyname|
390
+ begin
391
+ @compute_client.key_pairs.get(keyname)&.destroy
392
+ rescue => e
393
+ @logger.error "Failed to delete ephemeral keypair #{keyname}: #{e.message}"
394
+ end
395
+ end
396
+ @ephemeral_keypairs.clear
398
397
  end
399
398
  end
400
399
 
401
- # Enable root on a single host (the current one presumably) but only
402
- # if the username isn't 'root'
403
- def enable_root(host)
404
- if host['user'] != 'root'
405
- copy_ssh_to_root(host, @options)
406
- enable_root_login(host, @options)
407
- host['user'] = 'root'
408
- host.close
409
- end
400
+ # @!group Lookup Methods
401
+ # Lookup flavor by name
402
+ def flavor(f)
403
+ @logger.debug "Looking up flavor '#{f}'"
404
+ @compute_client.flavors.find { |x| x.name == f } || raise("Couldn't find flavor: #{f}")
410
405
  end
411
406
 
412
- #Get key_name from options or generate a new rsa key and add it to
413
- #OpenStack keypairs
414
- #
415
- #@param [Host] host The OpenStack host to provision
416
- #@api private
417
- def create_or_associate_keypair(host, keyname)
418
- if @options[:openstack_keyname]
419
- @logger.debug "Adding optional key_name #{@options[:openstack_keyname]} to #{host.name} (#{host[:vmhostname]})"
420
- keyname = @options[:openstack_keyname]
421
- else
422
- @logger.debug "Generate a new rsa key"
407
+ # Lookup image by name
408
+ def image(i)
409
+ @logger.debug "Looking up image '#{i}'"
410
+ @compute_client.images.find { |x| x.name == i } || raise("Couldn't find image: #{i}")
411
+ end
423
412
 
424
- # There is apparently an error that can occur when generating RSA keys, probably
425
- # due to some timing issue, probably similar to the issue described here:
426
- # https://github.com/negativecode/vines/issues/34
427
- # In order to mitigate this error, we will simply try again up to three times, and
428
- # then fail if we continue to error out.
429
- begin
430
- retries ||= 0
431
- key = OpenSSL::PKey::RSA.new 2048
432
- rescue OpenSSL::PKey::RSAError => e
433
- retries += 1
434
- if retries > 2
435
- @logger.notify "error generating RSA key #{retries} times, exiting"
436
- raise e
437
- end
438
- retry
439
- end
413
+ # Lookup network by name
414
+ def network(n)
415
+ @logger.debug "Looking up network '#{n}'"
416
+ @network_client.networks.find { |x| x.name == n } || raise("Couldn't find network: #{n}")
417
+ end
440
418
 
441
- type = key.ssh_type
442
- data = [ key.to_blob ].pack('m0')
443
- @logger.debug "Creating Openstack keypair '#{keyname}' for public key '#{type} #{data}'"
444
- @compute_client.create_key_pair keyname, "#{type} #{data}"
445
- host['ssh'][:key_data] = [ key.to_pem ]
419
+ # Validate security groups exist and return them in Fog-compatible format
420
+ def security_groups(sgs)
421
+ sgs.each do |sg|
422
+ @logger.debug "Openstack: Looking up security group '#{sg}'"
423
+ @compute_client.security_groups.find { |x| x.name == sg } || raise("Couldn't find security group: #{sg}")
446
424
  end
447
425
 
448
- host[:keyname] = keyname
426
+ # Return an array of strings, not hashes
427
+ sgs
428
+ end
429
+
430
+ # Determine if host should boot from volume
431
+ def boot_from_volume?(host)
432
+ host['root_volume'] && host['root_volume']['size']
433
+ end
434
+
435
+ # Lazy-init volume client
436
+ def volume_client_create
437
+ @volume_client ||= Fog::Volume.new(@credentials)
438
+ end
439
+
440
+ # Retrieve additional volumes definition from host
441
+ def get_volumes(host)
442
+ host['volumes'] || {}
449
443
  end
450
444
  end
451
445
  end