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.
@@ -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