beaker-kubevirt 1.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 +7 -0
- data/.devcontainer/.pryrc +10 -0
- data/.devcontainer/Dockerfile +49 -0
- data/.devcontainer/devcontainer.json +53 -0
- data/.rspec +3 -0
- data/.rubocop.yml +30 -0
- data/CHANGELOG.md +55 -0
- data/History.md +48 -0
- data/LICENSE +661 -0
- data/README.md +274 -0
- data/Rakefile +41 -0
- data/examples/cloud-init.yaml +57 -0
- data/examples/hosts.yaml +91 -0
- data/examples/usage.rb +70 -0
- data/lib/beaker/hypervisor/kubevirt.rb +1121 -0
- data/lib/beaker/hypervisor/kubevirt_helper.rb +593 -0
- data/lib/beaker/hypervisor/port_forward.rb +612 -0
- data/lib/beaker/kubevirt/version.rb +5 -0
- data/tests/nodesets/default.yaml +15 -0
- data/tests/spec/beaker/hypervisor/kubevirt_spec.rb +11 -0
- metadata +188 -0
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'tempfile'
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
require 'beaker'
|
|
11
|
+
rescue LoadError
|
|
12
|
+
# If beaker is not available, define a minimal Hypervisor base class
|
|
13
|
+
module Beaker
|
|
14
|
+
# Beaker support for the KubeVirt virtualization platform.
|
|
15
|
+
#
|
|
16
|
+
# This class implements a Beaker hypervisor driver for managing virtual machines
|
|
17
|
+
# on a KubeVirt-enabled Kubernetes cluster. It provides methods for provisioning,
|
|
18
|
+
# configuring, and cleaning up VMs, as well as handling networking and SSH access.
|
|
19
|
+
#
|
|
20
|
+
# The class expects to be initialized with a list of host definitions and an options hash.
|
|
21
|
+
# It supports multiple network modes (port-forward, nodeport, multus) and integrates
|
|
22
|
+
# with KubeVirt's APIs for VM lifecycle management.
|
|
23
|
+
#
|
|
24
|
+
# @see https://kubevirt.io/ KubeVirt Documentation
|
|
25
|
+
# @see https://github.com/voxpupuli/beaker Beaker Documentation
|
|
26
|
+
class Hypervisor
|
|
27
|
+
def initialize(hosts, options)
|
|
28
|
+
@hosts = hosts
|
|
29
|
+
@options = options
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module Beaker
|
|
36
|
+
# Beaker support for KubeVirt virtualization platform
|
|
37
|
+
class Kubevirt < Beaker::Hypervisor
|
|
38
|
+
SLEEPWAIT = 5
|
|
39
|
+
SSH_TIMEOUT = 300
|
|
40
|
+
|
|
41
|
+
# Default value for BEAKER_destroy environment variable
|
|
42
|
+
DEFAULT_BEAKER_DESTROY = 'yes'
|
|
43
|
+
# Values of BEAKER_destroy that indicate resources should be preserved
|
|
44
|
+
BEAKER_DESTROY_PRESERVE_VALUES = %w[no never onpass].freeze
|
|
45
|
+
|
|
46
|
+
# Shared Kubernetes 63-character upper bound used by names/labels in this class.
|
|
47
|
+
K8S_NAME_MAX = 63
|
|
48
|
+
# Kubernetes label-value maximum length under Kubernetes label value constraints (63 chars).
|
|
49
|
+
LABEL_VALUE_MAX = K8S_NAME_MAX
|
|
50
|
+
|
|
51
|
+
# RFC 1123 DNS label — same constraint kube-apiserver applies to namespaces.
|
|
52
|
+
NAMESPACE_RE = /\A[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\z/
|
|
53
|
+
|
|
54
|
+
# VMI phases that indicate the VM will never reach Running.
|
|
55
|
+
VMI_TERMINAL_PHASES = %w[Failed Succeeded].freeze
|
|
56
|
+
# virt-launcher container waiting reasons we treat as fatal.
|
|
57
|
+
FATAL_CONTAINER_WAITING_REASONS = %w[CrashLoopBackOff ImagePullBackOff ErrImagePull].freeze
|
|
58
|
+
# virt-launcher container termination reasons we treat as fatal.
|
|
59
|
+
FATAL_CONTAINER_TERMINATED_REASONS = %w[OOMKilled Error ContainerCannotRun].freeze
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# Create a new instance of the KubeVirt hypervisor object
|
|
63
|
+
#
|
|
64
|
+
# @param [Array<Host>] kubevirt_hosts The Array of KubeVirt hosts to provision
|
|
65
|
+
# @param [Hash{Symbol=>String}] options The options hash containing configuration values
|
|
66
|
+
#
|
|
67
|
+
# @option options [String] :kubeconfig Path to kubeconfig file
|
|
68
|
+
# @option options [String] :kubecontext Kubernetes context to use (optional)
|
|
69
|
+
# @option options [String] :namespace Kubernetes namespace for VMs (required)
|
|
70
|
+
# @option options [String] :kubevirt_service_account Kubernetes service account to use for PVC access (optional, required for cross-namespace PVC cloning)
|
|
71
|
+
# @option options [String] :kubevirt_vm_image Base VM image (PVC, container image, etc.)
|
|
72
|
+
# @option options [String] :kubevirt_network_mode Network mode (port-forward, nodeport, multus)
|
|
73
|
+
# @option options [String] :kubevirt_ssh_key SSH public key to inject
|
|
74
|
+
# @option options [String] :kubevirt_cpus CPU resources for VM
|
|
75
|
+
# @option options [String] :kubevirt_memory Memory resources for VM
|
|
76
|
+
# @option options [String] :kubevirt_memory_overhead Extra memory added to guest for the
|
|
77
|
+
# container memory limit (default: '512Mi'). Needed for Windows guests where KubeVirt's
|
|
78
|
+
# auto-computed virt-launcher overhead is too small and the compute container gets OOMKilled.
|
|
79
|
+
# @option options [String] :kubevirt_memory_request Memory request for the VM pod
|
|
80
|
+
# (default: same as :kubevirt_memory).
|
|
81
|
+
# @option options [Integer] :kubevirt_vm_ssh_port Port that SSH runs on inside the VM (default: 22)
|
|
82
|
+
# @option options [Boolean] :kubevirt_readiness_probe_disabled Skip the VMI SSH
|
|
83
|
+
# readinessProbe. Defaults to false for pod-network modes (port-forward, nodeport)
|
|
84
|
+
# and true for multus (the tcpSocket probe runs from the virt-launcher pod's
|
|
85
|
+
# network namespace and typically can't reach a bridge-only guest on a secondary NIC).
|
|
86
|
+
# @option options [Hash] :kubevirt_readiness_probe Probe tuning (all optional, snake_case):
|
|
87
|
+
# :initial_delay_seconds (30), :period_seconds (10), :timeout_seconds (3),
|
|
88
|
+
# :failure_threshold (60), :success_threshold (1). The default failure budget
|
|
89
|
+
# (period_seconds * failure_threshold = 600s) accommodates slow Windows first-boots.
|
|
90
|
+
# @option options [Integer] :timeout Timeout for operations. When the readiness probe is
|
|
91
|
+
# enabled, the effective wait is max(:timeout, probe budget + 60s).
|
|
92
|
+
# @option options [Boolean] :kubevirt_disable_virtio Disable virtio devices (for compatibility with Windows)
|
|
93
|
+
def initialize(kubevirt_hosts, options)
|
|
94
|
+
require 'beaker/hypervisor/kubevirt_helper'
|
|
95
|
+
|
|
96
|
+
super
|
|
97
|
+
@options = options
|
|
98
|
+
@namespace = @options[:namespace]
|
|
99
|
+
raise 'Namespace must be specified in options' unless @namespace
|
|
100
|
+
raise ArgumentError, "Invalid namespace #{@namespace.inspect}: must match RFC 1123 DNS label" unless @namespace.is_a?(String) && NAMESPACE_RE.match?(@namespace)
|
|
101
|
+
|
|
102
|
+
@service_account = @options[:kubevirt_service_account]
|
|
103
|
+
|
|
104
|
+
@logger = options[:logger]
|
|
105
|
+
@hosts = kubevirt_hosts
|
|
106
|
+
# Ensure the helper gets the validated namespace
|
|
107
|
+
@kubevirt_helper = KubevirtHelper.new(@options)
|
|
108
|
+
@test_group_identifier = "beaker-#{SecureRandom.hex(4)}"
|
|
109
|
+
@cleanup_called = false
|
|
110
|
+
@cleanup_mutex = Mutex.new
|
|
111
|
+
|
|
112
|
+
# Register at_exit handler to ensure cleanup happens even on non-success exits
|
|
113
|
+
# This handles cases like Ctrl+C, errors, or test failures that occur after
|
|
114
|
+
# provisioning but before normal cleanup
|
|
115
|
+
# Note: Each instance registers its own at_exit handler, but cleanup is idempotent
|
|
116
|
+
# and scoped to the specific test_group_identifier for this instance
|
|
117
|
+
# Skip registration during this gem's own rspec run (set by spec_helper).
|
|
118
|
+
# Gating on `defined?(RSpec)` misfired in downstream projects that run
|
|
119
|
+
# Beaker from inside their own rspec suite, suppressing the safety net
|
|
120
|
+
# for every consumer.
|
|
121
|
+
return if ENV['BEAKER_KUBEVIRT_DISABLE_AT_EXIT_CLEANUP'] == '1'
|
|
122
|
+
|
|
123
|
+
at_exit do
|
|
124
|
+
cleanup_on_exit
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
##
|
|
129
|
+
# Create and configure virtual machines in KubeVirt
|
|
130
|
+
def provision
|
|
131
|
+
# rubocop:disable Style/CombinableLoops
|
|
132
|
+
@logger.info("Starting KubeVirt provisioning with identifier: #{@test_group_identifier}")
|
|
133
|
+
|
|
134
|
+
@hosts.each do |host|
|
|
135
|
+
create_vm(host)
|
|
136
|
+
rescue StandardError, Interrupt => e
|
|
137
|
+
@logger.error("Error creating VM for host #{host.name}: #{e.message}")
|
|
138
|
+
cleanup
|
|
139
|
+
raise e
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@hosts.each do |host|
|
|
143
|
+
wait_for_vm_ready(host)
|
|
144
|
+
setup_networking(host)
|
|
145
|
+
rescue StandardError, Interrupt => e
|
|
146
|
+
@logger.error("Error provisioning host #{host.name}: #{e.message}")
|
|
147
|
+
@logger.error("Cleaning up host #{host.name} due to provisioning failure")
|
|
148
|
+
cleanup
|
|
149
|
+
raise e
|
|
150
|
+
end
|
|
151
|
+
# rubocop:enable Style/CombinableLoops
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
##
|
|
155
|
+
# Shutdown and destroy virtual machines in KubeVirt
|
|
156
|
+
def cleanup(timeout: 10, delay: 1)
|
|
157
|
+
@cleanup_mutex.synchronize do
|
|
158
|
+
return if @cleanup_called
|
|
159
|
+
|
|
160
|
+
@cleanup_called = true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
cleanup_impl(timeout: timeout, delay: delay)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
##
|
|
169
|
+
# Internal cleanup implementation that performs the actual cleanup work
|
|
170
|
+
# This is separate from cleanup() to avoid mutex issues when called from at_exit
|
|
171
|
+
def cleanup_impl(timeout: 10, delay: 1)
|
|
172
|
+
@logger.info('Cleaning up KubeVirt resources')
|
|
173
|
+
|
|
174
|
+
@hosts.each do |host|
|
|
175
|
+
next unless host['port_forwarder']
|
|
176
|
+
|
|
177
|
+
host_name = host.respond_to?(:name) ? host.name : host['name']
|
|
178
|
+
@logger.debug("Stopping port-forwarder for host: #{host_name}")
|
|
179
|
+
host['port_forwarder'].stop if host['port_forwarder'].respond_to?(:stop)
|
|
180
|
+
begin
|
|
181
|
+
Timeout.timeout(timeout) do
|
|
182
|
+
loop do
|
|
183
|
+
break if host['port_forwarder'].state == :stopped
|
|
184
|
+
|
|
185
|
+
@logger.debug("Waiting for port-forwarder to stop for host: #{host_name}")
|
|
186
|
+
sleep delay
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
rescue Timeout::Error
|
|
190
|
+
# Don't let one stuck forwarder block cluster-side cleanup; the
|
|
191
|
+
# forwarder's internal thread-kill path has already bounded the
|
|
192
|
+
# guest-side work by this point.
|
|
193
|
+
@logger.error("Port-forwarder for host #{host_name} did not stop within #{timeout}s; proceeding with cluster-side cleanup anyway")
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
@logger.error("Error stopping port-forwarder for host #{host_name}: #{e}")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
@logger.info("Cleaning up resources in namespace: #{@namespace}")
|
|
200
|
+
begin
|
|
201
|
+
safe_cleanup('cleaning up VMs') { @kubevirt_helper.cleanup_vms(@test_group_identifier) }
|
|
202
|
+
# DataVolumes (and their backing PVCs) — VMs first so the VMI
|
|
203
|
+
# releases the PVC before the DV delete tries to reclaim storage.
|
|
204
|
+
safe_cleanup('cleaning up DataVolumes') { @kubevirt_helper.cleanup_data_volumes(@test_group_identifier) }
|
|
205
|
+
safe_cleanup('cleaning up secrets') { @kubevirt_helper.cleanup_secrets(@test_group_identifier) }
|
|
206
|
+
safe_cleanup('cleaning up services') { @kubevirt_helper.cleanup_services(@test_group_identifier) }
|
|
207
|
+
ensure
|
|
208
|
+
# Finally unlink the kubeconfig-derived tempfiles — must be last
|
|
209
|
+
# because the preceding cleanups still need them for auth.
|
|
210
|
+
@kubevirt_helper.cleanup_temp_files
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
##
|
|
215
|
+
# Cleanup handler called at exit
|
|
216
|
+
# Only performs cleanup if:
|
|
217
|
+
# - Cleanup hasn't already been called
|
|
218
|
+
# - User hasn't requested to preserve hosts (via BEAKER_destroy=no or preserve_hosts option)
|
|
219
|
+
def cleanup_on_exit
|
|
220
|
+
# Check if user wants to preserve hosts
|
|
221
|
+
# BEAKER_destroy environment variable (no/never/onpass means preserve)
|
|
222
|
+
beaker_destroy = ENV.fetch('BEAKER_destroy', DEFAULT_BEAKER_DESTROY).downcase
|
|
223
|
+
preserve_from_env = BEAKER_DESTROY_PRESERVE_VALUES.include?(beaker_destroy)
|
|
224
|
+
|
|
225
|
+
# Check preserve_hosts option (can be set via --preserve-hosts flag)
|
|
226
|
+
preserve_from_option = @options[:preserve_hosts] || false
|
|
227
|
+
|
|
228
|
+
if preserve_from_env
|
|
229
|
+
@logger.info("Preserving KubeVirt resources (BEAKER_destroy=#{beaker_destroy})")
|
|
230
|
+
return
|
|
231
|
+
elsif preserve_from_option
|
|
232
|
+
@logger.info('Preserving KubeVirt resources (preserve_hosts option is set)')
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Atomically check and set cleanup_called flag
|
|
237
|
+
should_cleanup = false
|
|
238
|
+
@cleanup_mutex.synchronize do
|
|
239
|
+
unless @cleanup_called
|
|
240
|
+
@cleanup_called = true
|
|
241
|
+
should_cleanup = true
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
return unless should_cleanup
|
|
246
|
+
|
|
247
|
+
# Perform cleanup
|
|
248
|
+
@logger.info('at_exit: Performing cleanup of KubeVirt resources')
|
|
249
|
+
begin
|
|
250
|
+
# Call cleanup_impl to avoid the mutex lock in cleanup method
|
|
251
|
+
cleanup_impl
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
# Log but don't raise - we're already exiting
|
|
254
|
+
@logger.error("Error during at_exit cleanup: #{e.message}")
|
|
255
|
+
@logger.debug(e.backtrace.join("\n"))
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
##
|
|
260
|
+
# Execute a cleanup operation, logging any errors without re-raising them.
|
|
261
|
+
# @param [String] description A short description for the error message
|
|
262
|
+
def safe_cleanup(description)
|
|
263
|
+
yield
|
|
264
|
+
rescue StandardError => e
|
|
265
|
+
@logger.error("Error #{description}: #{e.message}")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
##
|
|
269
|
+
# Create a single VM for the given host
|
|
270
|
+
# @param [Host] host The host to create a VM for
|
|
271
|
+
def create_vm(host)
|
|
272
|
+
vm_name = generate_vm_name(host)
|
|
273
|
+
host['vm_name'] = vm_name
|
|
274
|
+
|
|
275
|
+
# Generate DataVolume name if applicable and store it for consistency
|
|
276
|
+
vm_image = host['kubevirt_vm_image'] || @options[:kubevirt_vm_image]
|
|
277
|
+
if vm_image&.start_with?('http://', 'https://')
|
|
278
|
+
base_name = vm_image.split('/').last
|
|
279
|
+
# Create a unique datavolume name by including the VM name in it
|
|
280
|
+
host['dv_name'] = sanitize_k8s_name("#{vm_name}-#{base_name}-dv")
|
|
281
|
+
elsif vm_image && !vm_image.start_with?('docker://', 'oci://')
|
|
282
|
+
# For PVC sources, we also need to clone to avoid sharing the same disk
|
|
283
|
+
source_pvc = vm_image.sub(%r{^pvc://}, '')
|
|
284
|
+
host['source_pvc'] = source_pvc
|
|
285
|
+
if source_pvc.include?('/')
|
|
286
|
+
_, source_pvc_name = source_pvc.split('/', 2)
|
|
287
|
+
else
|
|
288
|
+
source_pvc_name = source_pvc
|
|
289
|
+
end
|
|
290
|
+
host['dv_name'] = sanitize_k8s_name("#{vm_name}-#{source_pvc_name}")
|
|
291
|
+
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
cloud_init_data = generate_cloud_init(host)
|
|
295
|
+
cloud_init_secret_name = create_cloud_init_secret(host, cloud_init_data)
|
|
296
|
+
|
|
297
|
+
vm_spec = generate_vm_spec(host, vm_name, cloud_init_secret_name)
|
|
298
|
+
|
|
299
|
+
@logger.debug("Creating KubeVirt VM #{vm_name} for #{host.name}")
|
|
300
|
+
@kubevirt_helper.create_vm(vm_spec)
|
|
301
|
+
@logger.info("Created KubeVirt VM #{vm_name}")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
##
|
|
305
|
+
# Create a secret holding the cloud-init data
|
|
306
|
+
# @param [Host] host The host configuration
|
|
307
|
+
# @param [String] cloud_init_data Base64 encoded cloud-init data
|
|
308
|
+
# @return [String] The name of the created secret
|
|
309
|
+
def create_cloud_init_secret(host, cloud_init_data)
|
|
310
|
+
raise 'Cloud-init data must be provided' unless cloud_init_data
|
|
311
|
+
|
|
312
|
+
secret_name = "#{host['vm_name']}-cloud-init"
|
|
313
|
+
@logger.debug("Creating cloud-init secret #{secret_name} in namespace #{@namespace}")
|
|
314
|
+
|
|
315
|
+
secret_spec = {
|
|
316
|
+
'apiVersion' => 'v1',
|
|
317
|
+
'kind' => 'Secret',
|
|
318
|
+
'metadata' => {
|
|
319
|
+
'name' => secret_name,
|
|
320
|
+
'namespace' => @namespace,
|
|
321
|
+
'labels' => get_labels(host),
|
|
322
|
+
},
|
|
323
|
+
'type' => 'Opaque',
|
|
324
|
+
'data' => {
|
|
325
|
+
'userData' => Base64.strict_encode64(cloud_init_data),
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@kubevirt_helper.create_secret(secret_spec)
|
|
330
|
+
@logger.info("Created cloud-init secret #{secret_name} in namespace #{@namespace}")
|
|
331
|
+
secret_name
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
##
|
|
335
|
+
# Generate a unique VM name
|
|
336
|
+
# @param [Host] host The host
|
|
337
|
+
# @return [String] The generated VM name
|
|
338
|
+
def generate_vm_name(host)
|
|
339
|
+
host_name = host.respond_to?(:name) ? host.name : host['name']
|
|
340
|
+
sanitize_k8s_name("#{@test_group_identifier}-#{host_name}")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
##
|
|
344
|
+
# Generate cloud-init configuration for the VM
|
|
345
|
+
# @param [Host] host The host configuration
|
|
346
|
+
# @return [String] Base64 encoded cloud-init user data
|
|
347
|
+
def generate_cloud_init(host)
|
|
348
|
+
username = host['user'] || 'beaker'
|
|
349
|
+
ssh_key = find_ssh_public_key
|
|
350
|
+
|
|
351
|
+
host_name = host.respond_to?(:name) ? host.name : host['name']
|
|
352
|
+
|
|
353
|
+
cloud_init = {
|
|
354
|
+
'hostname' => host_name,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if host[:platform].include?('windows')
|
|
358
|
+
cloud_init['users'] = [
|
|
359
|
+
{
|
|
360
|
+
'name' => username,
|
|
361
|
+
'primary_group' => 'Administrators',
|
|
362
|
+
'ssh_authorized_keys' => [ssh_key],
|
|
363
|
+
'shell' => 'powershell.exe',
|
|
364
|
+
},
|
|
365
|
+
]
|
|
366
|
+
else
|
|
367
|
+
cloud_init['users'] = [
|
|
368
|
+
{
|
|
369
|
+
'name' => username,
|
|
370
|
+
'sudo' => 'ALL=(ALL) NOPASSWD:ALL',
|
|
371
|
+
'ssh_authorized_keys' => [ssh_key],
|
|
372
|
+
'shell' => '/bin/bash',
|
|
373
|
+
},
|
|
374
|
+
]
|
|
375
|
+
cloud_init['ssh_pwauth'] = false
|
|
376
|
+
cloud_init['disable_root'] = false
|
|
377
|
+
cloud_init['chpasswd'] = { 'expire' => false }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Add custom cloud-init if provided
|
|
381
|
+
if @options[:cloud_init]
|
|
382
|
+
begin
|
|
383
|
+
custom_init = YAML.safe_load(@options[:cloud_init])
|
|
384
|
+
rescue Psych::SyntaxError => e
|
|
385
|
+
raise ArgumentError, "Invalid cloud-init YAML in kubevirt_cloud_init option: #{e.message}"
|
|
386
|
+
end
|
|
387
|
+
cloud_init = cloud_init.merge(custom_init)
|
|
388
|
+
end
|
|
389
|
+
# It looks like the ssh-key is being wrapped to a new line by default, so we need to ensure it is properly formatted
|
|
390
|
+
cloud_init_yaml = Psych.dump(cloud_init, line_width: -1)
|
|
391
|
+
cloud_init_yaml.gsub!(/^---\n/, '') # Remove YAML document header
|
|
392
|
+
"#cloud-config\n#{cloud_init_yaml}"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
##
|
|
396
|
+
# Find SSH key pair (public and private keys)
|
|
397
|
+
# @return [Hash] Hash with :public_key (content) and :private_key_path
|
|
398
|
+
def find_ssh_key_pair
|
|
399
|
+
if @options[:kubevirt_ssh_key]
|
|
400
|
+
# If kubevirt_ssh_key is specified, it could be a public key path/content
|
|
401
|
+
ssh_key_value = @options[:kubevirt_ssh_key]
|
|
402
|
+
ssh_key_path = ssh_key_value.match?(%r{^[~/.]}) ? File.expand_path(ssh_key_value) : ssh_key_value
|
|
403
|
+
if File.exist?(ssh_key_path)
|
|
404
|
+
pub_key_path = ssh_key_path
|
|
405
|
+
pub_key_content = File.read(pub_key_path).strip
|
|
406
|
+
|
|
407
|
+
# Try to find matching private key
|
|
408
|
+
# Remove .pub extension if present to get private key path
|
|
409
|
+
private_key_path = pub_key_path.sub(/\.pub$/, '')
|
|
410
|
+
|
|
411
|
+
raise "Private key not found at #{private_key_path} (matching public key #{pub_key_path})" unless File.exist?(private_key_path)
|
|
412
|
+
|
|
413
|
+
{ public_key: pub_key_content, private_key_path: private_key_path }
|
|
414
|
+
else
|
|
415
|
+
# It's the public key content directly
|
|
416
|
+
# In this case, we can't determine the private key, so use default
|
|
417
|
+
@logger.warn('SSH public key provided as content, cannot determine private key path. Using default.')
|
|
418
|
+
{ public_key: @options[:kubevirt_ssh_key].strip, private_key_path: nil }
|
|
419
|
+
end
|
|
420
|
+
else
|
|
421
|
+
# Try common key types in order of preference
|
|
422
|
+
key_names = %w[id_ed25519 id_ecdsa id_rsa]
|
|
423
|
+
|
|
424
|
+
key_names.each do |key_name|
|
|
425
|
+
private_key_path = File.join(Dir.home, '.ssh', key_name)
|
|
426
|
+
pub_key_path = "#{private_key_path}.pub"
|
|
427
|
+
|
|
428
|
+
# Check if both private and public keys exist
|
|
429
|
+
if File.exist?(private_key_path) && File.exist?(pub_key_path)
|
|
430
|
+
pub_key_content = File.read(pub_key_path).strip
|
|
431
|
+
return { public_key: pub_key_content, private_key_path: private_key_path }
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
raise 'No matching SSH key pair found in ~/.ssh/. Specify with :ssh_key option.'
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Find SSH public key (for backward compatibility)
|
|
440
|
+
# @return [String] SSH public key content
|
|
441
|
+
def find_ssh_public_key
|
|
442
|
+
find_ssh_key_pair[:public_key]
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def get_labels(host)
|
|
446
|
+
raw_name = host.respond_to?(:name) ? host.name : host['name']
|
|
447
|
+
{
|
|
448
|
+
'beaker/test-group' => @test_group_identifier,
|
|
449
|
+
'beaker/host' => sanitize_k8s_label_value(raw_name),
|
|
450
|
+
}
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
##
|
|
454
|
+
# Sanitize a string to meet Kubernetes' label-value constraints:
|
|
455
|
+
# label values may be empty; otherwise they may be up to 63 characters,
|
|
456
|
+
# contain only [a-zA-Z0-9_.-], and must start and end with an
|
|
457
|
+
# alphanumeric character.
|
|
458
|
+
# Unlike sanitize_k8s_name this preserves dots, underscores, and case —
|
|
459
|
+
# labels allow them and losing that information makes `kubectl -l` lookups
|
|
460
|
+
# by hostname fail.
|
|
461
|
+
def sanitize_k8s_label_value(value)
|
|
462
|
+
cleaned = value.to_s.gsub(/[^-A-Za-z0-9_.]/, '-')
|
|
463
|
+
cleaned = cleaned[0, LABEL_VALUE_MAX]
|
|
464
|
+
cleaned = cleaned.sub(/\A[^A-Za-z0-9]+/, '').sub(/[^A-Za-z0-9]+\z/, '')
|
|
465
|
+
cleaned.empty? ? sanitize_k8s_name(value) : cleaned
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def disk_bus(host)
|
|
469
|
+
# Determine the disk bus type based on host configuration
|
|
470
|
+
if host['kubevirt_disable_virtio']
|
|
471
|
+
'sata'
|
|
472
|
+
else
|
|
473
|
+
'virtio'
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def eth_model(host)
|
|
478
|
+
# Determine the network model based on host configuration
|
|
479
|
+
if host['kubevirt_disable_virtio']
|
|
480
|
+
'e1000'
|
|
481
|
+
else
|
|
482
|
+
'virtio'
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
##
|
|
487
|
+
# Generate the hardware devices specification for the VM
|
|
488
|
+
# @param [Host] host The host configuration
|
|
489
|
+
# @return [Hash] Hardware devices specification
|
|
490
|
+
def generate_hardware_spec(host)
|
|
491
|
+
{
|
|
492
|
+
'disks' => [
|
|
493
|
+
{
|
|
494
|
+
'name' => 'rootdisk',
|
|
495
|
+
'disk' => {
|
|
496
|
+
'bus' => disk_bus(host),
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
'name' => 'cidata',
|
|
501
|
+
'disk' => {
|
|
502
|
+
'bus' => 'sata',
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
'interfaces' => [
|
|
507
|
+
{
|
|
508
|
+
'name' => 'default',
|
|
509
|
+
'bridge' => {},
|
|
510
|
+
'model' => eth_model(host),
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
'inputs' => [{
|
|
514
|
+
'bus' => 'usb',
|
|
515
|
+
'type' => 'tablet',
|
|
516
|
+
'name' => 'tablet',
|
|
517
|
+
}],
|
|
518
|
+
}
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
##
|
|
522
|
+
# Generate VM specification for KubeVirt
|
|
523
|
+
# @param [Host] host The host configuration
|
|
524
|
+
# @param [String] vm_name The VM name
|
|
525
|
+
# @param [String] cloud_init_secret Base64 encoded cloud-init data
|
|
526
|
+
# @return [Hash] VM specification
|
|
527
|
+
def generate_vm_spec(host, vm_name, cloud_init_secret)
|
|
528
|
+
cpu = host['kubevirt_cpus'] || @options[:kubevirt_cpus] || '1'
|
|
529
|
+
memory = host['kubevirt_memory'] || @options[:kubevirt_memory] || '2Gi'
|
|
530
|
+
# If the memory is a plain number, assume MiB
|
|
531
|
+
memory = "#{memory}Mi" if /^\d+$/.match?(memory)
|
|
532
|
+
|
|
533
|
+
overhead = host['kubevirt_memory_overhead'] || @options[:kubevirt_memory_overhead] || '512Mi'
|
|
534
|
+
overhead = "#{overhead}Mi" if /\A\d+\z/.match?(overhead.to_s)
|
|
535
|
+
memory_request = host['kubevirt_memory_request'] || @options[:kubevirt_memory_request] || memory
|
|
536
|
+
memory_request = "#{memory_request}Mi" if /\A\d+\z/.match?(memory_request.to_s)
|
|
537
|
+
memory_limit = "#{parse_memory_mib(memory) + parse_memory_mib(overhead)}Mi"
|
|
538
|
+
|
|
539
|
+
vm_image = host['kubevirt_vm_image'] || @options[:kubevirt_vm_image]
|
|
540
|
+
host_name = host.respond_to?(:name) ? host.name : host['name']
|
|
541
|
+
|
|
542
|
+
unless vm_image
|
|
543
|
+
raise ArgumentError,
|
|
544
|
+
"kubevirt_vm_image must be specified for host '#{host_name}' " \
|
|
545
|
+
'(set in host configuration or global options)'
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
{
|
|
549
|
+
'apiVersion' => 'kubevirt.io/v1',
|
|
550
|
+
'kind' => 'VirtualMachine',
|
|
551
|
+
'metadata' => {
|
|
552
|
+
'name' => vm_name,
|
|
553
|
+
'namespace' => @namespace,
|
|
554
|
+
'labels' => get_labels(host),
|
|
555
|
+
},
|
|
556
|
+
'spec' => {
|
|
557
|
+
'runStrategy' => 'Once',
|
|
558
|
+
'dataVolumeTemplates' => generate_root_volume_dvtemplate(vm_image, host),
|
|
559
|
+
'template' => {
|
|
560
|
+
'metadata' => {
|
|
561
|
+
'labels' => get_labels(host).merge({
|
|
562
|
+
'kubevirt.io/vm' => vm_name,
|
|
563
|
+
}),
|
|
564
|
+
},
|
|
565
|
+
'spec' => {
|
|
566
|
+
'domain' => {
|
|
567
|
+
'cpu' => {
|
|
568
|
+
'cores' => cpu.to_i,
|
|
569
|
+
'sockets' => 1,
|
|
570
|
+
'threads' => 1,
|
|
571
|
+
},
|
|
572
|
+
'memory' => {
|
|
573
|
+
'guest' => memory.to_s,
|
|
574
|
+
},
|
|
575
|
+
'resources' => {
|
|
576
|
+
'requests' => { 'memory' => memory_request },
|
|
577
|
+
'limits' => { 'memory' => memory_limit },
|
|
578
|
+
},
|
|
579
|
+
'devices' => generate_hardware_spec(host),
|
|
580
|
+
'features' => {
|
|
581
|
+
'acpi' => {},
|
|
582
|
+
# Enable SMM (System Management Mode) for secure boot
|
|
583
|
+
'smm' => {
|
|
584
|
+
'enabled' => true,
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
# Set to UEFI boot
|
|
588
|
+
'firmware' => {
|
|
589
|
+
'bootloader' => {
|
|
590
|
+
'efi' => {},
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
'hostname' => host_name,
|
|
595
|
+
'networks' => generate_networks_spec(host),
|
|
596
|
+
'readinessProbe' => generate_readiness_probe_spec(host),
|
|
597
|
+
'volumes' => [
|
|
598
|
+
generate_root_volume_spec(vm_image, host),
|
|
599
|
+
{
|
|
600
|
+
'name' => 'cidata',
|
|
601
|
+
'cloudInitNoCloud' => {
|
|
602
|
+
'secretRef' => {
|
|
603
|
+
'name' => cloud_init_secret,
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
generate_service_account_volume_spec,
|
|
608
|
+
].compact,
|
|
609
|
+
}.compact,
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
##
|
|
616
|
+
# Generate networks specification for the VM
|
|
617
|
+
# @param [Host] host The host configuration
|
|
618
|
+
# @return [Array] Networks specification
|
|
619
|
+
def generate_networks_spec(host)
|
|
620
|
+
if host['kubevirt_network_mode'] == 'multus'
|
|
621
|
+
# Multus network configuration
|
|
622
|
+
multus_networks = host['networks'] || []
|
|
623
|
+
multus_networks.map do |net|
|
|
624
|
+
{
|
|
625
|
+
'name' => net['name'],
|
|
626
|
+
'multus' => {
|
|
627
|
+
'networkName' => net['multus_network_name'],
|
|
628
|
+
},
|
|
629
|
+
}
|
|
630
|
+
end
|
|
631
|
+
else
|
|
632
|
+
# Default network configuration
|
|
633
|
+
[{
|
|
634
|
+
'name' => 'default',
|
|
635
|
+
'pod' => {},
|
|
636
|
+
}]
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
##
|
|
641
|
+
# Generate a DataVolume Template for the root disk
|
|
642
|
+
# @param [String] vm_image The VM image specification
|
|
643
|
+
# @param [Host] host The host configuration
|
|
644
|
+
# @return [Array] DataVolumeTemplate specifications
|
|
645
|
+
def generate_root_volume_dvtemplate(vm_image, host)
|
|
646
|
+
# Use the dv_name from the current host, not the last one in the array
|
|
647
|
+
dv_name = host['dv_name']
|
|
648
|
+
return nil unless dv_name
|
|
649
|
+
|
|
650
|
+
dv_spec = {
|
|
651
|
+
'metadata' => {
|
|
652
|
+
'name' => dv_name,
|
|
653
|
+
'labels' => get_labels(host),
|
|
654
|
+
'namespace' => @namespace,
|
|
655
|
+
},
|
|
656
|
+
'spec' => {
|
|
657
|
+
'storage' => {
|
|
658
|
+
'accessModes' => ['ReadWriteOnce'], # NOTE: This keeps the VM from being live migrated
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
# If a custom service account is specified, add it to the DataVolume spec
|
|
664
|
+
# so it can access the source PVC when required (including cross-namespace clones)
|
|
665
|
+
dv_spec['spec']['serviceAccountName'] = @service_account if @service_account
|
|
666
|
+
|
|
667
|
+
# Add storage size only if explicitly set or required (HTTP sources need it)
|
|
668
|
+
if host['disk_size']
|
|
669
|
+
dv_spec['spec']['storage']['resources'] = {
|
|
670
|
+
'requests' => {
|
|
671
|
+
'storage' => host['disk_size'].to_s,
|
|
672
|
+
},
|
|
673
|
+
}
|
|
674
|
+
elsif vm_image.start_with?('http://', 'https://')
|
|
675
|
+
# HTTP sources require a size since there's no source to infer from
|
|
676
|
+
dv_spec['spec']['storage']['resources'] = {
|
|
677
|
+
'requests' => {
|
|
678
|
+
'storage' => '10Gi', # Default size for HTTP sources
|
|
679
|
+
},
|
|
680
|
+
}
|
|
681
|
+
end
|
|
682
|
+
# For PVC clones without explicit size, omit storage.resources to inherit from source
|
|
683
|
+
|
|
684
|
+
# Set the appropriate source based on image type
|
|
685
|
+
if vm_image.start_with?('http://', 'https://')
|
|
686
|
+
dv_spec['spec']['source'] = {
|
|
687
|
+
'http' => {
|
|
688
|
+
'url' => vm_image,
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
elsif host['source_pvc']
|
|
692
|
+
name = host['source_pvc']
|
|
693
|
+
namespace = @namespace
|
|
694
|
+
namespace, name = host['source_pvc'].split('/', 2) if host['source_pvc'].include?('/')
|
|
695
|
+
# Clone from source PVC
|
|
696
|
+
dv_spec['spec']['source'] = {
|
|
697
|
+
'pvc' => {
|
|
698
|
+
'namespace' => namespace,
|
|
699
|
+
'name' => name,
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
[dv_spec]
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
##
|
|
708
|
+
# Generate root volume specification based on image type
|
|
709
|
+
# @param [String] vm_image The VM image specification
|
|
710
|
+
# @param [Host] host The host configuration
|
|
711
|
+
# @return [Hash] Volume specification
|
|
712
|
+
def generate_root_volume_spec(vm_image, host)
|
|
713
|
+
if host['dv_name']
|
|
714
|
+
# Use DataVolume (for HTTP URLs or PVC clones)
|
|
715
|
+
{
|
|
716
|
+
'name' => 'rootdisk',
|
|
717
|
+
'dataVolume' => {
|
|
718
|
+
'name' => host['dv_name'],
|
|
719
|
+
},
|
|
720
|
+
}
|
|
721
|
+
elsif vm_image.start_with?('docker://', 'oci://')
|
|
722
|
+
# Container image
|
|
723
|
+
# KubeVirt supports container images as root disks
|
|
724
|
+
# but we need to ensure the image is available in the cluster
|
|
725
|
+
{
|
|
726
|
+
'name' => 'rootdisk',
|
|
727
|
+
'containerDisk' => {
|
|
728
|
+
'image' => vm_image.sub(%r{^(docker|oci)://}, ''), # Remove protocol prefix
|
|
729
|
+
},
|
|
730
|
+
}
|
|
731
|
+
else
|
|
732
|
+
# Fallback: directly reference PVC (not recommended, kept for compatibility)
|
|
733
|
+
vm_image = vm_image.sub(%r{^pvc://}, '') if vm_image.start_with?('pvc://')
|
|
734
|
+
{
|
|
735
|
+
'name' => 'rootdisk',
|
|
736
|
+
'persistentVolumeClaim' => {
|
|
737
|
+
'claimName' => vm_image,
|
|
738
|
+
},
|
|
739
|
+
}
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
##
|
|
744
|
+
# Wait for VM to be ready and running
|
|
745
|
+
# @param [Host] host The host to wait for
|
|
746
|
+
def wait_for_vm_ready(host)
|
|
747
|
+
vm_name = host['vm_name']
|
|
748
|
+
@logger.info("Waiting for VM #{vm_name} to be ready...")
|
|
749
|
+
|
|
750
|
+
probe = generate_readiness_probe_spec(host)
|
|
751
|
+
timeout = effective_ready_timeout(probe)
|
|
752
|
+
|
|
753
|
+
begin
|
|
754
|
+
Timeout.timeout(timeout) do
|
|
755
|
+
loop do
|
|
756
|
+
# First, wait for the VM to exist
|
|
757
|
+
vm = @kubevirt_helper.get_vm(vm_name)
|
|
758
|
+
break if vm
|
|
759
|
+
|
|
760
|
+
@logger.debug("VM #{vm_name} not found yet, waiting...")
|
|
761
|
+
sleep SLEEPWAIT
|
|
762
|
+
end
|
|
763
|
+
loop do
|
|
764
|
+
# First, check if the VM still exists
|
|
765
|
+
vm = @kubevirt_helper.get_vm(vm_name)
|
|
766
|
+
unless vm
|
|
767
|
+
@logger.error("VM #{vm_name} no longer exists")
|
|
768
|
+
raise "VM #{vm_name} was deleted unexpectedly"
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Then check if the VM is running
|
|
772
|
+
vmi = @kubevirt_helper.get_vmi(vm_name)
|
|
773
|
+
phase = vmi&.dig('status', 'phase')
|
|
774
|
+
|
|
775
|
+
if phase == 'Running' && vmi_ssh_ready?(vmi, probe)
|
|
776
|
+
@logger.debug("VM #{vm_name} is ready")
|
|
777
|
+
break
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
raise "VMI #{vm_name} entered terminal phase #{phase} before becoming Ready" if VMI_TERMINAL_PHASES.include?(phase)
|
|
781
|
+
|
|
782
|
+
check_virt_launcher_health!(vm_name)
|
|
783
|
+
|
|
784
|
+
sleep SLEEPWAIT
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
rescue Timeout::Error
|
|
788
|
+
@logger.error("Timeout waiting for VM #{vm_name} to be ready")
|
|
789
|
+
raise
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
##
|
|
794
|
+
# Whether the VMI is fully ready. If a readiness probe is configured,
|
|
795
|
+
# require the KubeVirt-managed `Ready` status condition to be True (that
|
|
796
|
+
# tracks the tcpSocket probe, so sshd is actually listening). Otherwise
|
|
797
|
+
# fall back to phase=Running alone — the historical behavior.
|
|
798
|
+
# @param [Hash] vmi VMI object from the kubevirt API
|
|
799
|
+
# @param [Hash, nil] probe The probe spec, or nil when disabled
|
|
800
|
+
# @return [Boolean]
|
|
801
|
+
def vmi_ssh_ready?(vmi, probe)
|
|
802
|
+
return true if probe.nil?
|
|
803
|
+
|
|
804
|
+
condition = Array(vmi&.dig('status', 'conditions'))
|
|
805
|
+
.find { |c| c['type'] == 'Ready' }
|
|
806
|
+
if condition.nil?
|
|
807
|
+
@logger.debug('VMI has no Ready condition yet, continuing to wait')
|
|
808
|
+
return false
|
|
809
|
+
end
|
|
810
|
+
return true if condition['status'] == 'True'
|
|
811
|
+
|
|
812
|
+
@logger.debug("VMI Ready=#{condition['status']} reason=#{condition['reason']} message=#{condition['message']}")
|
|
813
|
+
false
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
##
|
|
817
|
+
# Compute the effective outer Timeout for wait_for_vm_ready. When a probe
|
|
818
|
+
# is configured, the probe's worst-case budget (initial delay + period *
|
|
819
|
+
# failureThreshold) must fit, otherwise a user who left :timeout at the
|
|
820
|
+
# default would see the probe truncated before it can fail legitimately.
|
|
821
|
+
# @param [Hash, nil] probe
|
|
822
|
+
# @return [Integer] seconds
|
|
823
|
+
def effective_ready_timeout(probe)
|
|
824
|
+
base = @options[:timeout] || 300
|
|
825
|
+
return base if probe.nil?
|
|
826
|
+
|
|
827
|
+
probe_budget = probe['initialDelaySeconds'] +
|
|
828
|
+
(probe['periodSeconds'] * probe['failureThreshold']) + 60
|
|
829
|
+
[base, probe_budget].max
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
##
|
|
833
|
+
# Inspect the virt-launcher pod backing a VMI and raise with a specific reason
|
|
834
|
+
# if it has already failed (OOMKilled, image pull errors, crash loops, etc.)
|
|
835
|
+
# so we fail fast instead of waiting the full timeout.
|
|
836
|
+
# @param [String] vm_name
|
|
837
|
+
def check_virt_launcher_health!(vm_name)
|
|
838
|
+
pod = @kubevirt_helper.get_virt_launcher_pod(vm_name)
|
|
839
|
+
return unless pod
|
|
840
|
+
|
|
841
|
+
phase = pod.dig('status', 'phase')
|
|
842
|
+
raise "virt-launcher pod for #{vm_name} entered phase #{phase}" if phase == 'Failed'
|
|
843
|
+
|
|
844
|
+
statuses = Array(pod.dig('status', 'containerStatuses')) +
|
|
845
|
+
Array(pod.dig('status', 'initContainerStatuses'))
|
|
846
|
+
statuses.each do |cs|
|
|
847
|
+
name = cs['name']
|
|
848
|
+
waiting_reason = cs.dig('state', 'waiting', 'reason')
|
|
849
|
+
if FATAL_CONTAINER_WAITING_REASONS.include?(waiting_reason)
|
|
850
|
+
raise "virt-launcher container #{name} for #{vm_name} is #{waiting_reason}: " \
|
|
851
|
+
"#{cs.dig('state', 'waiting', 'message')}"
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
last_term = cs.dig('lastState', 'terminated') || cs.dig('state', 'terminated')
|
|
855
|
+
reason = last_term && last_term['reason']
|
|
856
|
+
next unless FATAL_CONTAINER_TERMINATED_REASONS.include?(reason)
|
|
857
|
+
|
|
858
|
+
hint = (reason == 'OOMKilled' && name == 'compute') ? ' — increase :kubevirt_memory_overhead (default 512Mi)' : ''
|
|
859
|
+
raise "virt-launcher container #{name} for #{vm_name} terminated: #{reason}#{hint}"
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
##
|
|
864
|
+
# The guest-side port SSH listens on, shared between the VMI readiness probe
|
|
865
|
+
# and client-side networking setup so they can never drift apart.
|
|
866
|
+
# @param [Host] host The host configuration
|
|
867
|
+
# @return [Integer] SSH port inside the guest
|
|
868
|
+
def vm_ssh_port(host)
|
|
869
|
+
host['kubevirt_vm_ssh_port'] || @options[:kubevirt_vm_ssh_port] || 22
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
##
|
|
873
|
+
# Whether the SSH readinessProbe should be skipped for this host.
|
|
874
|
+
# Defaults to false for pod-network modes (port-forward, nodeport) and true
|
|
875
|
+
# for multus (the probe runs from the virt-launcher pod's netns and
|
|
876
|
+
# typically can't reach a bridge-only guest on a secondary interface).
|
|
877
|
+
# @param [Host] host The host configuration
|
|
878
|
+
# @return [Boolean]
|
|
879
|
+
def readiness_probe_disabled?(host)
|
|
880
|
+
host_val = host['kubevirt_readiness_probe_disabled']
|
|
881
|
+
return host_val unless host_val.nil?
|
|
882
|
+
|
|
883
|
+
opt_val = @options[:kubevirt_readiness_probe_disabled]
|
|
884
|
+
return opt_val unless opt_val.nil?
|
|
885
|
+
|
|
886
|
+
(host['kubevirt_network_mode'] || 'port-forward') == 'multus'
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
##
|
|
890
|
+
# Build the KubeVirt readinessProbe hash for a host, or nil if disabled.
|
|
891
|
+
# A tcpSocket probe on the guest's SSH port lets KubeVirt publish a
|
|
892
|
+
# Ready condition once sshd is actually accepting connections — wait_for_vm_ready
|
|
893
|
+
# gates on that condition so Beaker doesn't attempt SSH before the guest is up.
|
|
894
|
+
# Per-host overrides win over global options; snake_case keys map to the
|
|
895
|
+
# camelCase probe fields KubeVirt expects.
|
|
896
|
+
# @param [Host] host The host configuration
|
|
897
|
+
# @return [Hash, nil]
|
|
898
|
+
def generate_readiness_probe_spec(host)
|
|
899
|
+
return nil if readiness_probe_disabled?(host)
|
|
900
|
+
|
|
901
|
+
cfg = (@options[:kubevirt_readiness_probe] || {})
|
|
902
|
+
.merge(host['kubevirt_readiness_probe'] || {})
|
|
903
|
+
{
|
|
904
|
+
'tcpSocket' => { 'port' => vm_ssh_port(host) },
|
|
905
|
+
'initialDelaySeconds' => cfg[:initial_delay_seconds] || 30,
|
|
906
|
+
'periodSeconds' => cfg[:period_seconds] || 10,
|
|
907
|
+
'timeoutSeconds' => cfg[:timeout_seconds] || 3,
|
|
908
|
+
'failureThreshold' => cfg[:failure_threshold] || 60,
|
|
909
|
+
'successThreshold' => cfg[:success_threshold] || 1,
|
|
910
|
+
}
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
##
|
|
914
|
+
# Setup networking for the VM
|
|
915
|
+
# @param [Host] host The host to setup networking for
|
|
916
|
+
def setup_networking(host)
|
|
917
|
+
network_mode = host['kubevirt_network_mode'] || 'port-forward'
|
|
918
|
+
|
|
919
|
+
case network_mode
|
|
920
|
+
when 'port-forward'
|
|
921
|
+
setup_port_forward(host, vm_ssh_port(host))
|
|
922
|
+
when 'nodeport'
|
|
923
|
+
setup_nodeport(host)
|
|
924
|
+
when 'multus'
|
|
925
|
+
setup_multus_networking(host)
|
|
926
|
+
else
|
|
927
|
+
raise "Unsupported network mode: #{network_mode}"
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Configure SSH keys - ensure we use the matching private key for the public key
|
|
931
|
+
# that was injected into the VM via cloud-init
|
|
932
|
+
configure_ssh_keys(host)
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
##
|
|
936
|
+
# Setup port-forward networking
|
|
937
|
+
# @param [Host] host The host
|
|
938
|
+
# @param [Integer] host_port The port on the VM to forward to (typically 22 for SSH)
|
|
939
|
+
def setup_port_forward(host, host_port)
|
|
940
|
+
require 'beaker/hypervisor/port_forward'
|
|
941
|
+
vm_name = host['vm_name']
|
|
942
|
+
|
|
943
|
+
@options['ssh']['port'] = nil
|
|
944
|
+
local_port = find_free_port
|
|
945
|
+
|
|
946
|
+
@logger.info("Using local port #{local_port} for port-forward to VM #{vm_name}")
|
|
947
|
+
|
|
948
|
+
host['ip'] = '127.0.0.1' # Port forwarding will use localhost
|
|
949
|
+
host['port'] = nil # Port that clients should connect to (local port)
|
|
950
|
+
# Get current SSH options and modify them
|
|
951
|
+
ssh_options = host['ssh'] || {}
|
|
952
|
+
ssh_options['port'] = local_port
|
|
953
|
+
host['ssh'] = ssh_options
|
|
954
|
+
|
|
955
|
+
@logger.debug("Setting up port-forward for VM #{vm_name} from localhost:#{local_port} to VM port #{host_port}")
|
|
956
|
+
@logger.debug("Configured SSH connection: host['ip']=#{host['ip']}, host['port']=#{host['port']}, host['ssh']['port']=#{host['ssh']['port']}")
|
|
957
|
+
|
|
958
|
+
# Setup port forwarding from local_port to host_port (22) on the VM
|
|
959
|
+
host['port_forwarder'] = @kubevirt_helper.setup_port_forward(vm_name, host_port, local_port)
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
##
|
|
963
|
+
# Setup NodePort networking
|
|
964
|
+
# @param [Host] host The host
|
|
965
|
+
def setup_nodeport(host)
|
|
966
|
+
vm_name = host['vm_name']
|
|
967
|
+
service_name = "#{vm_name}-ssh"
|
|
968
|
+
|
|
969
|
+
@logger.debug("Creating NodePort service for VM #{vm_name}")
|
|
970
|
+
service = @kubevirt_helper.create_nodeport_service(vm_name, service_name, @test_group_identifier)
|
|
971
|
+
|
|
972
|
+
node_port = service.dig('spec', 'ports', 0, 'nodePort')
|
|
973
|
+
node_ip = @kubevirt_helper.node_ip
|
|
974
|
+
|
|
975
|
+
host['ip'] = node_ip
|
|
976
|
+
host['port'] = node_port
|
|
977
|
+
# Get current SSH options and modify them
|
|
978
|
+
ssh_options = host['ssh'] || {}
|
|
979
|
+
ssh_options['port'] = node_port
|
|
980
|
+
host['ssh'] = ssh_options
|
|
981
|
+
host['service_name'] = service_name
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
##
|
|
985
|
+
# Setup Multus networking (external bridge)
|
|
986
|
+
# @param [Host] host The host
|
|
987
|
+
def setup_multus_networking(host)
|
|
988
|
+
vm_name = host['vm_name']
|
|
989
|
+
@logger.debug("Getting external IP for VM #{vm_name} via Multus")
|
|
990
|
+
|
|
991
|
+
# For Multus, we need to wait for the VM to get an external IP
|
|
992
|
+
external_ip = wait_for_external_ip(vm_name)
|
|
993
|
+
|
|
994
|
+
host['ip'] = external_ip
|
|
995
|
+
host['port'] = 22
|
|
996
|
+
# Get current SSH options and modify them
|
|
997
|
+
ssh_options = host['ssh'] || {}
|
|
998
|
+
ssh_options['port'] = 22
|
|
999
|
+
host['ssh'] = ssh_options
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
##
|
|
1003
|
+
# Configure SSH keys for the host
|
|
1004
|
+
# Ensures the private key used for SSH matches the public key injected via cloud-init
|
|
1005
|
+
# @param [Host] host The host to configure
|
|
1006
|
+
def configure_ssh_keys(host)
|
|
1007
|
+
ssh_options = host['ssh'] || {}
|
|
1008
|
+
|
|
1009
|
+
# Keep idle SSH sessions alive: long-running commands with no output
|
|
1010
|
+
# otherwise hit kube-apiserver / port-forward proxy / NAT idle timeouts.
|
|
1011
|
+
ssh_options['keepalive'] = true unless ssh_options.key?('keepalive')
|
|
1012
|
+
ssh_options['keepalive_interval'] = 60 unless ssh_options.key?('keepalive_interval')
|
|
1013
|
+
ssh_options['keepalive_maxcount'] = 5 unless ssh_options.key?('keepalive_maxcount')
|
|
1014
|
+
|
|
1015
|
+
key_pair = find_ssh_key_pair
|
|
1016
|
+
|
|
1017
|
+
# Only set the private key path if we found a matching pair
|
|
1018
|
+
if key_pair[:private_key_path]
|
|
1019
|
+
# Prepend the matching key, preserve any pre-existing keys so fallbacks
|
|
1020
|
+
# (agent-forwarded, project-wide CI key) still work if ours is unusable.
|
|
1021
|
+
existing_keys = Array(ssh_options['keys'])
|
|
1022
|
+
merged_keys = [key_pair[:private_key_path]] + existing_keys
|
|
1023
|
+
merged_keys.uniq!
|
|
1024
|
+
ssh_options['keys'] = merged_keys
|
|
1025
|
+
|
|
1026
|
+
@logger.info("Configured SSH to use private key #{File.basename(key_pair[:private_key_path])}")
|
|
1027
|
+
@logger.debug("SSH private key full path: #{key_pair[:private_key_path]}")
|
|
1028
|
+
else
|
|
1029
|
+
@logger.warn('Could not determine private key path, SSH will use default keys')
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
host['ssh'] = ssh_options
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
##
|
|
1036
|
+
# Wait for VM to get external IP via Multus
|
|
1037
|
+
# @param [String] vm_name The VM name
|
|
1038
|
+
# @return [String] External IP address
|
|
1039
|
+
def wait_for_external_ip(vm_name)
|
|
1040
|
+
timeout = @options[:timeout] || 300
|
|
1041
|
+
start_time = Time.now
|
|
1042
|
+
|
|
1043
|
+
loop do
|
|
1044
|
+
vmi = @kubevirt_helper.get_vmi(vm_name)
|
|
1045
|
+
interfaces = vmi.dig('status', 'interfaces')
|
|
1046
|
+
|
|
1047
|
+
external_interface = interfaces.find { |iface| iface['name'] != 'default' }
|
|
1048
|
+
return external_interface['ipAddress'] if external_interface && external_interface['ipAddress']
|
|
1049
|
+
|
|
1050
|
+
raise "Timeout waiting for external IP for VM #{vm_name}" if Time.now - start_time > timeout
|
|
1051
|
+
|
|
1052
|
+
sleep SLEEPWAIT
|
|
1053
|
+
end
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
##
|
|
1057
|
+
# Find a free local port for port forwarding
|
|
1058
|
+
# @return [Integer] Free port number
|
|
1059
|
+
def find_free_port
|
|
1060
|
+
server = TCPServer.new(0)
|
|
1061
|
+
port = server.addr[1]
|
|
1062
|
+
server.close
|
|
1063
|
+
port
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
##
|
|
1067
|
+
# Sanitize a string to make it RFC 1035 DNS label compliant
|
|
1068
|
+
# - Lowercase
|
|
1069
|
+
# - Only alphanumeric characters and hyphens
|
|
1070
|
+
# - Start with a letter
|
|
1071
|
+
# - End with an alphanumeric character
|
|
1072
|
+
# - Maximum length of 63 characters
|
|
1073
|
+
# @param [String] name The string to sanitize
|
|
1074
|
+
# @return [String] RFC 1035 compliant string
|
|
1075
|
+
# Parse a memory string ("4Gi", "512Mi", "2048") into an integer number of MiB.
|
|
1076
|
+
# @param [String, Integer] value
|
|
1077
|
+
# @return [Integer] MiB
|
|
1078
|
+
def parse_memory_mib(value)
|
|
1079
|
+
s = value.to_s.strip
|
|
1080
|
+
case s
|
|
1081
|
+
when /\A(\d+)Gi\z/ then Regexp.last_match(1).to_i * 1024
|
|
1082
|
+
when /\A(\d+)Mi\z/ then Regexp.last_match(1).to_i
|
|
1083
|
+
when /\A(\d+)\z/ then s.to_i
|
|
1084
|
+
else
|
|
1085
|
+
raise ArgumentError, "Cannot parse memory value #{value.inspect} (expected Gi/Mi suffix or bare MiB)"
|
|
1086
|
+
end
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
def sanitize_k8s_name(name)
|
|
1090
|
+
# Remove invalid characters, replace with hyphens
|
|
1091
|
+
sanitized = name.downcase.gsub(/[^a-z0-9-]/, '-')
|
|
1092
|
+
|
|
1093
|
+
# Ensure it starts with a letter
|
|
1094
|
+
sanitized = "x#{sanitized}" unless /[a-z]/.match?(sanitized[0])
|
|
1095
|
+
|
|
1096
|
+
# Ensure it doesn't end with a hyphen
|
|
1097
|
+
sanitized = "#{sanitized}0" if sanitized[-1] == '-'
|
|
1098
|
+
|
|
1099
|
+
# Ensure it's not too long (max 63 chars for DNS label)
|
|
1100
|
+
sanitized = sanitized[0..62] if sanitized.length > 63
|
|
1101
|
+
|
|
1102
|
+
sanitized
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
##
|
|
1106
|
+
# Generate a service account volume specification if a service account is set.
|
|
1107
|
+
# This defines a volume that can be used to attach the configured service account
|
|
1108
|
+
# to the VM pod; it is independent of any use of service accounts in DataVolumes.
|
|
1109
|
+
# @return [Hash, nil] Service account volume specification or nil
|
|
1110
|
+
def generate_service_account_volume_spec
|
|
1111
|
+
return nil unless @service_account
|
|
1112
|
+
|
|
1113
|
+
{
|
|
1114
|
+
'name' => 'service-account-volume',
|
|
1115
|
+
'serviceAccount' => {
|
|
1116
|
+
'serviceAccountName' => @service_account,
|
|
1117
|
+
},
|
|
1118
|
+
}
|
|
1119
|
+
end
|
|
1120
|
+
end
|
|
1121
|
+
end
|