Class: Beaker::Openstack

Inherits:
Hypervisor
  • Object
show all
Defined in:
lib/beaker/hypervisor/openstack.rb

Overview

Additional volumes created via openstack_volume_support are preserved and not deleted by cleanup

Constant Summary collapse

SLEEPWAIT =

Seconds to wait between retry attempts for VM boot

5

Provision Methods collapse

Lookup Methods collapse

Instance Method Summary collapse

Constructor Details

#initialize(openstack_hosts, options) ⇒ Openstack

Create a new OpenStack hypervisor object

Parameters:

  • openstack_hosts (Array<Host>) —

    Array of hosts to provision

  • options (Hash{Symbol=>Object}) —

    Configuration options:

Options Hash (options):

  • :openstack_api_key (String) —

    Required API key

  • :openstack_username (String) —

    Required username

  • :openstack_auth_url (String) —

    Required auth URL

  • :openstack_project_name (String) —

    Project name (v3) or nil

  • :openstack_project_id (String) —

    Project ID (v3) or nil

  • :openstack_user_domain (String) —

    Optional user domain for v3

  • :openstack_user_domain_id (String) —

    Optional user domain ID for v3

  • :openstack_project_domain (String) —

    Optional project domain for v3

  • :openstack_project_domain_id (String) —

    Optional project domain ID for v3

  • :openstack_region (String) —

    The region that each OpenStack instance should be provisioned on (optional)

  • :openstack_network (String) —

    Required network for VM

  • :openstack_floating_ip (Bool) —

    Whether to assign a floating IP

  • :floating_ip_pool (String) —

    Required floating network ID when floating IPs are enabled

  • :openstack_volume_support (Bool) —

    Whether to provision additional volumes

  • :openstack_keyname (String) —

    Optional pre-existing keypair name

  • :security_group (Array<String>) —

    Optional security groups

  • :timeout (Integer) —

    Timeout in seconds for VM boot

  • :jenkins_build_url (String) —

    Optional metadata

  • :department (String) —

    Optional metadata

  • :project (String) —

    Optional metadata



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/beaker/hypervisor/openstack.rb', line 34

def initialize(openstack_hosts, options)
  require 'fog/openstack'

  @options = options
  @logger  = options[:logger]
  @hosts   = openstack_hosts

  # Initialize shared resources and mutexes for thread safety
  @vms = []
  @floating_ips = []
  @vms_mutex = Mutex.new
  @fip_mutex = Mutex.new
  @keypairs_mutex = Mutex.new
  @cleanup_mutex = Mutex.new
  @cleanup_ran = false

  # Track keys we create so we only delete what we own
  @ephemeral_keypairs = []

  # Required options
  raise 'You must specify :openstack_api_key'      unless @options[:openstack_api_key]
  raise 'You must specify :openstack_username'     unless @options[:openstack_username]
  raise 'You must specify :openstack_auth_url'     unless @options[:openstack_auth_url]
  raise 'You must specify :openstack_network'      unless @options[:openstack_network]
  raise 'You must specify :openstack_floating_ip (true/false)' if @options[:openstack_floating_ip].nil?

  # Floating IP pool is required only if floating IPs are enabled
  raise 'You must specify :floating_ip_pool when using floating IPs' if @options[:openstack_floating_ip] && !@options[:floating_ip_pool]

  # Keystone version detection
  # Matches both /v3 and /v3/ endings to avoid false negatives
  is_v3 = @options[:openstack_auth_url].match?(%r{/v3/?$})

  # Enforce Keystone v3 only (v2 is no longer supported)
  raise 'Keystone v2 is no longer supported. Please use a /v3 auth URL.' unless is_v3

  # project_name or project_id required
  raise 'Specify project_name or project_id' unless @options[:openstack_project_name] || @options[:openstack_project_id]

  # cannot specify both
  raise 'Do not mix project_name and project_id' if @options[:openstack_project_name] && @options[:openstack_project_id]

  # user_domain XOR user_domain_id
  raise 'Specify either :openstack_user_domain or :openstack_user_domain_id, not both' if @options[:openstack_user_domain] && @options[:openstack_user_domain_id]

  # project_domain XOR project_domain_id
  raise 'Specify either :openstack_project_domain or :openstack_project_domain_id, not both' if @options[:openstack_project_domain] && @options[:openstack_project_domain_id]

  # fog-openstack limitation: do not mix _id and non-_id fields
  if (@options[:openstack_project_name] ||
      @options[:openstack_user_domain] ||
      @options[:openstack_project_domain]) &&
     (@options[:openstack_project_id] ||
      @options[:openstack_user_domain_id] ||
      @options[:openstack_project_domain_id])
    raise 'Do not mix _id and non-_id values for project/user domains due to fog-openstack limitations'
  end

  # Build credential scope depending on Keystone version
  extra_credentials =
    if @options[:openstack_project_id]
      { openstack_project_id: @options[:openstack_project_id] }
    else
      { openstack_project_name: @options[:openstack_project_name] }
    end

  # Base credentials (no duplicate tenant/project fields)
  @credentials = {
    provider: :openstack,
    openstack_auth_url: @options[:openstack_auth_url],
    openstack_api_key: @options[:openstack_api_key],
    openstack_username: @options[:openstack_username],
    openstack_region: @options[:openstack_region]
  }.merge(extra_credentials)

  # Keystone v3 domain scoping
  @credentials[:openstack_user_domain_id] = @options[:openstack_user_domain_id] if @options[:openstack_user_domain_id]
  @credentials[:openstack_user_domain] ||= @options[:openstack_user_domain] || 'Default'

  @credentials[:openstack_project_domain_id] = @options[:openstack_project_domain_id] if @options[:openstack_project_domain_id]
  @credentials[:openstack_project_domain] ||= @options[:openstack_project_domain] || 'Default'

  # Create clients
  # These are created once during initialization (no memoization needed)
  @compute_client = Fog::Compute.new(@credentials)
  raise "Unable to create OpenStack Compute instance" unless @compute_client

  @network_client = Fog::Network.new(@credentials)
  raise "Unable to create OpenStack Network instance" unless @network_client

  # --- openstack_volume_support normalization ---
  # Accepts string or boolean input, but only true/false are valid outcomes
  val = @options[:openstack_volume_support].to_s.downcase
  @options[:openstack_volume_support] = true  if val == "true"
  @options[:openstack_volume_support] = false if val == "false"

  raise "Invalid openstack_volume_support setting" unless [true, false].include?(@options[:openstack_volume_support])
end

Instance Method Details

#boot_from_volume?(host) ⇒ Boolean

Determine if host should boot from volume

Returns:

  • (Boolean)


431
432
433
# File 'lib/beaker/hypervisor/openstack.rb', line 431

def boot_from_volume?(host)
  host['root_volume'] && host['root_volume']['size']
end

#cleanup ⇒ Object

Cleanup all resources Ephemeral keypairs and VMs are destroyed; allocated Floating IPs are released; additional volumes are preserved



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/beaker/hypervisor/openstack.rb', line 360

def cleanup
  @logger.notify "Cleaning up OpenStack"

  @vms_mutex.synchronize do
    @vms.each do |vm|
      begin
        @logger.debug "Destroying #{vm.name}"
        vm.destroy rescue nil
      rescue => e
        @logger.error "Cleanup error (VM): #{e.message}"
      end
    end
    @vms.clear
  end

  @fip_mutex.synchronize do
    @floating_ips.each do |fip|
      begin
        ip_addr = fip.respond_to?(:floating_ip_address) ? fip.floating_ip_address : (fip.respond_to?(:ip) ? fip.ip : 'unknown')
        @logger.debug "Releasing Floating IP #{ip_addr}"
        fip.destroy rescue nil
      rescue => e
        @logger.error "Cleanup error (FIP): #{e.message}"
      end
    end
    @floating_ips.clear
  end

  @keypairs_mutex.synchronize do
    @ephemeral_keypairs.each do |keyname|
      begin
        @compute_client.key_pairs.get(keyname)&.destroy
      rescue => e
        @logger.error "Failed to delete ephemeral keypair #{keyname}: #{e.message}"
      end
    end
    @ephemeral_keypairs.clear
  end
end

#create_instance_resources(host) ⇒ Object

Create all resources required for a single VM



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/beaker/hypervisor/openstack.rb', line 158

def create_instance_resources(host)
  @logger.notify "Provisioning #{host.name}"

  # Revert to standard Beaker managed style (10 character random string) for VM hostname
  host[:vmhostname] = ('a'..'z').to_a.sample(10).join

  floating_ip = nil
  if @options[:openstack_floating_ip]
    floating_ip = get_floating_ip
    # Track the Floating IP object immediately for cleanup safety
    @fip_mutex.synchronize { @floating_ips << floating_ip }

    # Capture the actual IP string for connectivity
    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)
    host[:ip] = actual_ip
  end

  create_or_associate_keypair(host, host[:vmhostname])

  server_opts = {
    name: host[:vmhostname],
    flavor_ref: flavor(host[:flavor]).id,
    nics: [{ 'net_id' => network(@options[:openstack_network]).id }],
    key_name: host[:keyname],
    security_groups: @options[:security_group] ? security_groups(@options[:security_group]) : nil,
    user_data: host[:user_data] || "#cloud-config\nmanage_etc_hosts: true\n"
  }

  if boot_from_volume?(host)
    server_opts[:block_device_mapping_v2] = [{
      uuid: image(host[:image]).id,
      source_type: "image",
      destination_type: "volume",
      volume_size: host['root_volume']['size'].to_i,
      delete_on_termination: host['root_volume'].key?('delete_on_termination') ? !!host['root_volume']['delete_on_termination'] : true,
      boot_index: 0
    }]
  else
    server_opts[:image_ref] = image(host[:image]).id
  end

  vm = @compute_client.servers.create(server_opts)
  vm.wait_for(@options[:timeout] || 600) { respond_to?(:ready?) ? ready? : state == 'ACTIVE' }

  # Register VM for cleanup
  @vms_mutex.synchronize { @vms << vm }

  if @options[:openstack_floating_ip] && floating_ip
    # Associate the IP via Compute client to ensure compatibility with Neutron objects
    @compute_client.associate_address(vm.id, host[:ip])
  else
    # Prefer IPv4 address if multiple networks exist
    addr = vm.addresses.values.flatten.find { |a| a['version'] == 4 }
    host[:ip] = addr && addr['addr']

    if host[:ip].nil?
      @logger.warn "[#{host.name}] No IPv4 address found; VM may be IPv6-only"
    end
  end

  @logger.debug "[#{host.name}] Assigned IP #{host[:ip]}"

  # Metadata is best-effort (some clouds disable it)
  begin
    vm..update(
      jenkins_build_url: @options[:jenkins_build_url].to_s,
      department: @options[:department].to_s,
      project: @options[:project].to_s
    )
  rescue => e
    @logger.debug("[#{host.name}] Metadata update failed: #{e.message}")
  end

  host.wait_for_port(22)
  enable_root(host)

  provision_storage(host, vm) if @options[:openstack_volume_support]

rescue => e
  @logger.error "Provision failed: #{e.message}"

  @cleanup_mutex.synchronize do
    unless @cleanup_ran
      @cleanup_ran = true
      cleanup
    end
  end

  raise e
end

#create_or_associate_keypair(host, keyname) ⇒ Object

Get key_name from options or generate a new RSA key and add it to OpenStack keypairs



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/beaker/hypervisor/openstack.rb', line 250

def create_or_associate_keypair(host, keyname)
  if @options[:openstack_keyname]
    host[:keyname] = @options[:openstack_keyname]
    @logger.debug "Using existing keypair #{@options[:openstack_keyname]}"
  else
    # Remove any existing ephemeral key with this name to avoid collisions
    @compute_client.key_pairs.get(keyname)&.destroy

    # Generate new RSA keypair
    key = OpenSSL::PKey::RSA.new(2048)
    type = key.ssh_type
    data = [key.to_blob].pack('m0')
    @compute_client.create_key_pair keyname, "#{type} #{data}"

    # Track ephemeral keypairs for cleanup
    @keypairs_mutex.synchronize { @ephemeral_keypairs << keyname }

    # Inject private key into Beaker host
    host['ssh'][:key_data] = [key.to_pem]
    host[:keyname] = keyname
  end
end

#enable_root(host) ⇒ Object

Enables root access for a single host when its current user is not 'root'



350
351
352
353
354
355
356
# File 'lib/beaker/hypervisor/openstack.rb', line 350

def enable_root(host)
  return if host['user'] == 'root'
  copy_ssh_to_root(host, @options)
  (host, @options)
  host['user'] = 'root'
  host.close
end

#flavor(f) ⇒ Object

Lookup flavor by name



402
403
404
405
# File 'lib/beaker/hypervisor/openstack.rb', line 402

def flavor(f)
  @logger.debug "Looking up flavor '#{f}'"
  @compute_client.flavors.find { |x| x.name == f } || raise("Couldn't find flavor: #{f}")
end

#get_floating_ip ⇒ Object

Get a floating IP address from the configured pool supports both network name and network UUID



275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/beaker/hypervisor/openstack.rb', line 275

def get_floating_ip
  # Check if floating_ip_pool is a UUID; if not, look up the network ID by name
  pool_id = if @options[:floating_ip_pool] =~ /^[0-9a-f-]{36}$/i
              @options[:floating_ip_pool]
            else
              @logger.debug "Looking up floating IP pool network by name: #{@options[:floating_ip_pool]}"
              network(@options[:floating_ip_pool]).id
            end

  @network_client.floating_ips.create(
    floating_network_id: pool_id
  )
end

#get_volumes(host) ⇒ Object

Retrieve additional volumes definition from host



441
442
443
# File 'lib/beaker/hypervisor/openstack.rb', line 441

def get_volumes(host)
  host['volumes'] || {}
end

#image(i) ⇒ Object

Lookup image by name



408
409
410
411
# File 'lib/beaker/hypervisor/openstack.rb', line 408

def image(i)
  @logger.debug "Looking up image '#{i}'"
  @compute_client.images.find { |x| x.name == i } || raise("Couldn't find image: #{i}")
end

#network(n) ⇒ Object

Lookup network by name



414
415
416
417
# File 'lib/beaker/hypervisor/openstack.rb', line 414

def network(n)
  @logger.debug "Looking up network '#{n}'"
  @network_client.networks.find { |x| x.name == n } || raise("Couldn't find network: #{n}")
end

#provision ⇒ Object

Main provisioning entrypoint



135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/beaker/hypervisor/openstack.rb', line 135

def provision
  if @options[:create_in_parallel]
    Thread.abort_on_exception = true
    @logger.notify "Provisioning OpenStack in parallel"
    provision_parallel
  else
    @logger.notify "Provisioning OpenStack sequentially"
    provision_sequential
  end

  hack_etc_hosts @hosts, @options
end

#provision_parallel ⇒ Object



148
149
150
151
# File 'lib/beaker/hypervisor/openstack.rb', line 148

def provision_parallel
  threads = @hosts.map { |host| Thread.new { create_instance_resources(host) } }
  threads.each(&:join)
end

#provision_sequential ⇒ Object



153
154
155
# File 'lib/beaker/hypervisor/openstack.rb', line 153

def provision_sequential
  @hosts.each { |host| create_instance_resources(host) }
end

#provision_storage(host, vm) ⇒ Object

Provision additional volumes (always preserved, never deleted automatically)



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/beaker/hypervisor/openstack.rb', line 290

def provision_storage(host, vm)
  return unless @options[:openstack_volume_support]

  volumes = get_volumes(host)
  return if volumes.empty?

  volume_client_create
  device_index = 0

  volumes.each do |vol_name, vol_def|
    # Skip root volume if already handled via boot_from_volume
    next if vol_name == 'root' && boot_from_volume?(host)

    @logger.debug "Creating volume #{vol_name} for #{host.name}"

    vol = @volume_client.volumes.create(
      name: vol_name,
      size: vol_def['size'].to_i,
      description: vol_def['description'] || "Beaker volume: #{host.name}:#{vol_name}"
    )

    # Wait for Cinder to fully provision the volume
    vol.wait_for(300) do
      vol.reload
      raise "Volume #{vol.name} entered error state: #{vol.status}" if vol.status =~ /error/i
      vol.status == 'available'
    end

    # Device naming starts at /dev/vdc to avoid conflicts with root/ephemeral disks
    device_letter = ('c'.ord + device_index)
    raise "Too many volumes, cannot allocate device name" if device_letter > 'z'.ord
    device = "/dev/vd#{device_letter.chr}"
    device_index += 1

    # Attach with retry (Nova attach sometimes races)
    attempts = 0
    begin
      vm.attach_volume(vol.id, device)
    rescue => e
      attempts += 1
      if attempts < 3
        @logger.debug "Attach failed, retrying in 2s... (#{e.message})"
        sleep 2
        retry
      end
      raise "Failed to attach volume #{vol_name} after 3 attempts: #{e}"
    end

    # Wait for Nova to complete attachment
    vol.wait_for(120) do
      vol.reload
      vol.status == 'in-use'
    end
  end

  # Ensure host['ssh'] hash exists for key injection if needed later
  host['ssh'] ||= {}
end

#security_groups(sgs) ⇒ Object

Validate security groups exist and return them in Fog-compatible format



420
421
422
423
424
425
426
427
428
# File 'lib/beaker/hypervisor/openstack.rb', line 420

def security_groups(sgs)
  sgs.each do |sg|
    @logger.debug "Openstack: Looking up security group '#{sg}'"
    @compute_client.security_groups.find { |x| x.name == sg } || raise("Couldn't find security group: #{sg}")
  end

  # Return an array of strings, not hashes
  sgs
end

#volume_client_create ⇒ Object

Lazy-init volume client



436
437
438
# File 'lib/beaker/hypervisor/openstack.rb', line 436

def volume_client_create
  @volume_client ||= Fog::Volume.new(@credentials)
end