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.
- checksums.yaml +4 -4
- data/.github/release.yml +41 -0
- data/.github/workflows/release.yml +95 -20
- data/.github/workflows/test.yml +33 -6
- data/CHANGELOG.md +24 -0
- data/Gemfile +1 -1
- data/README.md +202 -45
- data/beaker-openstack.gemspec +25 -20
- data/lib/beaker/hypervisor/openstack.rb +363 -369
- data/lib/beaker-openstack/version.rb +1 -1
- data/openstack.md +98 -68
- data/spec/beaker/hypervisor/openstack_spec.rb +246 -177
- metadata +63 -76
|
@@ -1,451 +1,445 @@
|
|
|
1
1
|
module Beaker
|
|
2
|
-
#Beaker support for OpenStack
|
|
3
|
-
#
|
|
4
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
36
|
-
@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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
95
|
+
{ openstack_project_id: @options[:openstack_project_id] }
|
|
62
96
|
else
|
|
63
|
-
|
|
97
|
+
{ openstack_project_name: @options[:openstack_project_name] }
|
|
64
98
|
end
|
|
65
|
-
end
|
|
66
99
|
|
|
67
|
-
#
|
|
100
|
+
# Base credentials (no duplicate tenant/project fields)
|
|
68
101
|
@credentials = {
|
|
69
|
-
:
|
|
70
|
-
:
|
|
71
|
-
:
|
|
72
|
-
:
|
|
73
|
-
:
|
|
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
|
|
78
|
-
if
|
|
79
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
196
|
+
server_opts[:image_ref] = image(host[:image]).id
|
|
172
197
|
end
|
|
173
|
-
end
|
|
174
198
|
|
|
175
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
vol.wait_for { ready? }
|
|
202
|
+
# Register VM for cleanup
|
|
203
|
+
@vms_mutex.synchronize { @vms << vm }
|
|
215
204
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
287
|
-
def
|
|
288
|
-
@
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if
|
|
322
|
-
@logger.debug "
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
#
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
@
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
@
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
#
|
|
402
|
-
#
|
|
403
|
-
def
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
#
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
@
|
|
445
|
-
|
|
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
|
-
|
|
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
|