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,612 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubeclient'
4
+ require 'faye/websocket'
5
+ require 'eventmachine'
6
+ require 'socket'
7
+ require 'logger'
8
+ require 'json'
9
+ require 'uri'
10
+
11
+ # KubeVirtPortForwarder acts as a local TCP proxy for a port on a KubeVirt VMI.
12
+ # It handles the entire lifecycle of discovering the VMI, establishing a
13
+ # WebSocket connection via the Kubernetes API, and proxying data.
14
+ #
15
+ # Each accepted client connection makes a single attempt to open the WebSocket
16
+ # to the VMI. If that attempt fails, the client socket is closed so the caller
17
+ # (e.g. Beaker's SSH client) sees the connection failure promptly and learns
18
+ # the real state of the port, rather than waiting behind an internal retry loop.
19
+ #
20
+ # See the bottom of this file for a complete usage example.
21
+ #
22
+ class KubeVirtPortForwarder
23
+ attr_reader :state, :local_port
24
+
25
+ # The subprotocol required by the Kubernetes API for multiplexed streaming.
26
+ # This protocol defines channels for stdin, stdout, stderr, and a special
27
+ # error channel, which allows for out-of-band error reporting.
28
+ STREAM_PROTOCOL = 'v4.channel.k8s.io'
29
+
30
+ # A KubeVirt-specific subprotocol for a raw, un-multiplexed data stream.
31
+ PLAIN_STREAM_PROTOCOL = 'plain.kubevirt.io'
32
+
33
+ # The channel byte for the primary data stream (stdin/stdout).
34
+ DATA_CHANNEL = "\x00"
35
+
36
+ # The channel byte for the error stream from the server.
37
+ ERROR_CHANNEL = "\x01"
38
+
39
+ # Upper bound on how long we'll wait for EventMachine.reactor_running? to
40
+ # become true after kicking off EventMachine.run in its own thread. Healthy
41
+ # startup is well under 100ms; five seconds is only reached when something
42
+ # is wrong (native-ext load failure, incompatible libcrypto, etc.).
43
+ REACTOR_STARTUP_TIMEOUT = 5
44
+
45
+ # Upper bound on how long we'll wait for the reactor thread to exit during
46
+ # shutdown after EventMachine.stop. If startup timed out, the reactor thread
47
+ # is alive but hung and EventMachine.stop is a no-op, so we must kill it
48
+ # rather than block forever on join.
49
+ REACTOR_SHUTDOWN_TIMEOUT = 5
50
+
51
+ # Send a WebSocket ping every N seconds. SSH keepalive packets are payload
52
+ # frames that some upstream WS-aware proxies don't count against their idle
53
+ # timer; protocol-level pings always do.
54
+ WEBSOCKET_PING_INTERVAL = 60
55
+
56
+ # Class-level tracking of EventMachine reactor ownership
57
+ # EventMachine has a single global reactor, so we need to track which
58
+ # forwarder instance started it to avoid stopping it prematurely
59
+ @reactor_owner_mutex = Mutex.new
60
+ @reactor_owner = nil
61
+
62
+ class << self
63
+ attr_accessor :reactor_owner_mutex, :reactor_owner
64
+ end
65
+
66
+ # @param kube_client [Kubeclient::Client] An initialized kubeclient client.
67
+ # @param namespace [String] The Kubernetes namespace of the VMI.
68
+ # @param vmi_name [String] The name of the VirtualMachineInstance.
69
+ # @param target_port [Integer] The port inside the VMI to connect to (e.g., 22 for SSH).
70
+ # @param local_port [Integer] The local TCP port to listen on.
71
+ # @param logger [Logger] An optional logger instance.
72
+ # @param on_error [Proc] An optional callback (proc or lambda) to handle errors.
73
+ # @param ssl_options [Hash] Optional SSL options to use instead of kube_client's (to preserve :ca_file)
74
+ def initialize(kube_client:, namespace:, vmi_name:, target_port:, local_port:, logger: nil, on_error: nil, ssl_options: nil)
75
+ @kube_client = kube_client
76
+ @namespace = namespace
77
+ @vmi_name = vmi_name
78
+ @target_port = target_port
79
+ @local_port = local_port
80
+ @on_error = on_error
81
+ @logger = logger || Logger.new($stdout, level: :info)
82
+ @ssl_options = ssl_options # Use provided ssl_options if available
83
+
84
+ @state = :new
85
+ @mutex = Mutex.new
86
+ @server_thread = nil
87
+ @reactor_thread = nil
88
+ @connection_threads = []
89
+ @shutdown = false
90
+ end
91
+
92
+ # Starts the local TCP server and the EventMachine reactor in background threads.
93
+ def start
94
+ return unless state_transition_to(:starting)
95
+
96
+ @logger.info("Starting local proxy on 127.0.0.1:#{@local_port} for vmi://#{@namespace}/#{@vmi_name}:#{@target_port}")
97
+
98
+ # Reset shutdown flag
99
+ @shutdown = false
100
+
101
+ # Start the EventMachine reactor in a dedicated thread to handle WebSocket I/O.
102
+ # EventMachine has a single global reactor, so we need to check if it's already
103
+ # running. Guarding only on EventMachine.reactor_running? would race: a second
104
+ # forwarder entering this block before the first reactor thread has actually
105
+ # become "running" would also see false, set itself as owner, and spawn a
106
+ # second EventMachine.run thread. Guard on reactor_owner.nil? as well so the
107
+ # owner is locked in atomically — a non-owner just waits for the in-progress
108
+ # reactor to become ready.
109
+ start_reactor = false
110
+ self.class.reactor_owner_mutex.synchronize do
111
+ if self.class.reactor_owner.nil? && !EventMachine.reactor_running?
112
+ start_reactor = true
113
+ self.class.reactor_owner = self
114
+ end
115
+ end
116
+
117
+ if start_reactor
118
+ @reactor_thread = Thread.new { EventMachine.run }
119
+ wait_for_reactor_ready
120
+ @logger.debug('Started EventMachine reactor (owned by this forwarder)')
121
+ else
122
+ wait_for_existing_reactor_ready
123
+ @logger.debug('Using existing EventMachine reactor (owned by another forwarder)')
124
+ end
125
+
126
+ @server = TCPServer.new('127.0.0.1', @local_port)
127
+ state_transition_to(:running)
128
+
129
+ @server_thread = Thread.new do
130
+ loop do
131
+ break if @state == :stopping || @shutdown
132
+
133
+ begin
134
+ # Accept a connection from a client (e.g., Beaker's SSH).
135
+ client_socket = @server.accept
136
+ peer_ip, peer_port = client_socket.peeraddr(false).values_at(3, 1)
137
+ @logger.debug("Accepted connection from #{peer_ip}:#{peer_port}")
138
+
139
+ # Handle the entire KubeVirt connection lifecycle in a new thread.
140
+ conn_thread = Thread.new { handle_connection(client_socket) }
141
+ @mutex.synchronize { @connection_threads << conn_thread }
142
+ rescue IOError
143
+ # This is expected when @server.close is called in stop()
144
+ @logger.debug("Server on port #{@local_port} stopped accepting connections.")
145
+ break
146
+ end
147
+ end
148
+ end
149
+ rescue StandardError => e
150
+ report_error(e)
151
+ state_transition_to(:error)
152
+ stop # Attempt a clean shutdown on startup failure
153
+ end
154
+
155
+ # Stops the server, closes all active connections, and cleans up threads.
156
+ # This method is designed to be idempotent.
157
+ def stop
158
+ return unless state_transition_to(:stopping)
159
+
160
+ @logger.info("Stopping port forwarder for vmi://#{@namespace}/#{@vmi_name}:#{@target_port}")
161
+
162
+ # Set shutdown flag to signal threads to stop gracefully
163
+ @shutdown = true
164
+
165
+ # Close the main server socket to stop accepting new connections.
166
+ @server&.close
167
+ @server = nil
168
+
169
+ # Wait for the main server thread to finish.
170
+ @server_thread&.join
171
+
172
+ # Clean up any active connection threads.
173
+ threads_to_join = []
174
+ @mutex.synchronize do
175
+ threads_to_join = @connection_threads.dup
176
+ @connection_threads.clear
177
+ end
178
+
179
+ # Give threads a chance to terminate gracefully
180
+ threads_to_join.each do |thread|
181
+ # Raise an exception in the thread to interrupt blocking I/O
182
+ thread.raise(IOError, 'Port forwarder shutting down') if thread.alive?
183
+ end
184
+
185
+ # Wait for threads to finish with a timeout
186
+ deadline = Time.now + 5
187
+ threads_to_join.each do |thread|
188
+ remaining = deadline - Time.now
189
+ if remaining.positive?
190
+ # Wait for thread to finish, but don't wait longer than the deadline
191
+ begin
192
+ thread.join(remaining)
193
+ rescue StandardError => e
194
+ # Thread may raise an exception when we called thread.raise
195
+ @logger.debug("Thread exited with exception during shutdown: #{e.class}: #{e.message}")
196
+ end
197
+ end
198
+
199
+ # If thread is still alive after timeout, kill it as last resort
200
+ next unless thread.alive?
201
+
202
+ @logger.warn("Force-killing connection thread that didn't shut down gracefully")
203
+ thread.kill
204
+ begin
205
+ thread.join
206
+ rescue StandardError
207
+ nil
208
+ end
209
+ end
210
+
211
+ # Stop the EventMachine reactor only if this instance owns it.
212
+ # EventMachine has a single global reactor, so we must not stop it
213
+ # if other forwarders are still using it.
214
+ should_stop_reactor = false
215
+ self.class.reactor_owner_mutex.synchronize do
216
+ if self.class.reactor_owner == self
217
+ should_stop_reactor = true
218
+ self.class.reactor_owner = nil
219
+ end
220
+ end
221
+
222
+ if should_stop_reactor
223
+ EventMachine.stop if EventMachine.reactor_running?
224
+ shut_down_reactor_thread
225
+ @logger.debug('Stopped EventMachine reactor (owned by this forwarder)')
226
+ else
227
+ @logger.debug('Not stopping EventMachine reactor (owned by another forwarder)')
228
+ end
229
+
230
+ @logger.info('Port forwarder stopped.')
231
+ state_transition_to(:stopped)
232
+ end
233
+
234
+ private
235
+
236
+ # Wait for the reactor thread to exit, with a bounded timeout and a
237
+ # last-resort kill. Mirrors the connection-thread shutdown logic above.
238
+ # The reactor thread's exception (if any) was already surfaced via
239
+ # Thread#value in wait_for_reactor_ready, so swallowing the same class
240
+ # here during teardown is safe; signals (Interrupt/SystemExit) propagate.
241
+ def shut_down_reactor_thread
242
+ reactor_thread = @reactor_thread
243
+ return unless reactor_thread
244
+
245
+ begin
246
+ joined = reactor_thread.join(REACTOR_SHUTDOWN_TIMEOUT)
247
+ rescue StandardError, ScriptError => e
248
+ @logger.debug("Reactor thread terminated with exception during shutdown: #{e.class}: #{e.message}")
249
+ return
250
+ end
251
+
252
+ return if joined
253
+
254
+ @logger.warn("Force-killing reactor thread that didn't shut down within #{REACTOR_SHUTDOWN_TIMEOUT}s")
255
+ reactor_thread.kill
256
+ # Thread#kill won't interrupt an uninterruptible native call, so bound this
257
+ # join too — we'd rather leak a hung thread than block shutdown forever.
258
+ begin
259
+ joined = reactor_thread.join(REACTOR_SHUTDOWN_TIMEOUT)
260
+ rescue StandardError, ScriptError
261
+ joined = true
262
+ end
263
+ return if joined
264
+
265
+ @logger.error("Reactor thread still alive #{REACTOR_SHUTDOWN_TIMEOUT}s after kill; abandoning it (likely stuck in a native call)")
266
+ end
267
+
268
+ # Block until EventMachine.reactor_running? becomes true, or fail fast
269
+ # if the reactor thread died during startup or the deadline elapses.
270
+ def wait_for_reactor_ready
271
+ deadline = monotonic_now + REACTOR_STARTUP_TIMEOUT
272
+ until EventMachine.reactor_running?
273
+ unless @reactor_thread.alive?
274
+ begin
275
+ @reactor_thread.value # re-raises the thread's exception, if any
276
+ # The reactor thread typically dies from non-StandardError causes such as
277
+ # LoadError (native extension failure) or other ScriptError subclasses.
278
+ # Rescue Exception so we can wrap and re-raise *any* cause with context;
279
+ # we do not swallow it (raise below preserves :cause and backtrace).
280
+ rescue Exception => e # rubocop:disable Lint/RescueException
281
+ startup_error = RuntimeError.new(
282
+ "EventMachine reactor thread exited during startup: #{e.class}: #{e.message}",
283
+ )
284
+ startup_error.set_backtrace(e.backtrace)
285
+ raise startup_error, cause: e
286
+ end
287
+ raise 'EventMachine reactor thread exited during startup with no exception'
288
+ end
289
+ raise "EventMachine reactor did not become ready within #{REACTOR_STARTUP_TIMEOUT}s" if (monotonic_now > deadline) && !EventMachine.reactor_running?
290
+
291
+ sleep 0.1
292
+ end
293
+ end
294
+
295
+ # Wait (bounded) for a reactor that another forwarder is starting to become
296
+ # ready. If the owning forwarder's startup fails, it clears reactor_owner in
297
+ # its rescue/stop path; if that happens before the reactor came up, we bail
298
+ # out rather than wait the full deadline.
299
+ def wait_for_existing_reactor_ready
300
+ deadline = monotonic_now + REACTOR_STARTUP_TIMEOUT
301
+ until EventMachine.reactor_running?
302
+ raise 'EventMachine reactor owner cleared before reactor became ready' if self.class.reactor_owner.nil?
303
+ raise "EventMachine reactor did not become ready within #{REACTOR_STARTUP_TIMEOUT}s" if (monotonic_now > deadline) && !EventMachine.reactor_running?
304
+
305
+ sleep 0.1
306
+ end
307
+ end
308
+
309
+ # Monotonic clock for deadline arithmetic. Time.now is wall-clock and can
310
+ # jump forwards/backwards on NTP adjustments, producing spurious timeouts
311
+ # or longer-than-expected waits.
312
+ def monotonic_now
313
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
314
+ end
315
+
316
+ # Handles a single client connection from start to finish.
317
+ # @param client_socket [TCPSocket] The socket connected to the client.
318
+ def handle_connection(client_socket)
319
+ websocket = establish_websocket_with_retry(client_socket, retries: 1)
320
+ if websocket
321
+ @logger.debug('Connection to VMI established, proxying traffic')
322
+ proxy_traffic(client_socket, websocket)
323
+ else
324
+ @logger.error('Failed to establish connection to VMI. Closing client socket.')
325
+ client_socket.close
326
+ end
327
+ rescue IOError => e
328
+ # IOError is raised during shutdown - this is expected
329
+ if e.is_a?(EOFError)
330
+ @logger.debug('Connection closed cleanly (EOF) - normal shutdown')
331
+ elsif e.message.include?('shutting down')
332
+ @logger.debug('Connection handler shutting down gracefully')
333
+ else
334
+ report_error(e, 'Error in connection handler thread')
335
+ end
336
+ begin
337
+ client_socket.close
338
+ rescue StandardError
339
+ nil
340
+ end
341
+ rescue StandardError => e
342
+ report_error(e, 'Error in connection handler thread')
343
+ begin
344
+ client_socket.close
345
+ rescue StandardError
346
+ nil
347
+ end
348
+ ensure
349
+ @mutex.synchronize { @connection_threads.delete(Thread.current) }
350
+ end
351
+
352
+ # Attempts to establish the WebSocket connection. The retry loop is retained
353
+ # for flexibility, but callers pass retries: 1 so a failure is reported to
354
+ # the client immediately rather than masked by internal waiting.
355
+ # @param client_socket [TCPSocket] The client socket, used to check if the client is still connected.
356
+ # @return [Faye::WebSocket::Client, nil] The connected WebSocket client or nil if it fails.
357
+ def establish_websocket_with_retry(client_socket, retries: 10, delay: 5)
358
+ uri = @kube_client.api_endpoint
359
+ # Build base URL preserving any path prefix (e.g., /k8s/clusters/xyz in Rancher)
360
+ base_url = "#{uri.scheme}://#{uri.host}"
361
+ base_url += ":#{uri.port}" if uri.port && ![80, 443].include?(uri.port)
362
+ # Preserve the path prefix if it exists, removing any trailing API paths
363
+ path_prefix = uri.path.to_s.sub(%r{/api.*$}, '')
364
+ base_url += path_prefix unless path_prefix.empty? || path_prefix == '/'
365
+ base_ws_url = base_url.sub(/^http/, 'ws')
366
+ ns = URI.encode_www_form_component(@namespace)
367
+ vmi = URI.encode_www_form_component(@vmi_name)
368
+ target_port = Integer(@target_port, exception: false)
369
+ raise ArgumentError, "Invalid target port: #{@target_port.inspect}. Expected an integer between 1 and 65535." unless target_port&.between?(1, 65_535)
370
+
371
+ # Use the protocol-suffixed `/portforward/<port>/tcp` form. Both this and
372
+ # the bare `/portforward/<port>` route are registered in KubeVirt 1.7+,
373
+ # but some API-aggregation paths (notably Rancher's cluster proxy against
374
+ # KubeVirt 1.8) return 404 on the bare form. The `/tcp` form is what
375
+ # `virtctl port-forward` sends and works on every supported version.
376
+ url = "#{base_ws_url}/apis/subresources.kubevirt.io/v1/namespaces/#{ns}/virtualmachineinstances/#{vmi}/portforward/#{target_port}/tcp"
377
+ @logger.debug("WebSocket URL: #{url}")
378
+
379
+ auth_token = @kube_client.auth_options[:bearer_token]
380
+ @logger.debug("Using auth token: #{auth_token ? 'present' : 'absent'}")
381
+ headers = {}
382
+ headers['Authorization'] = "Bearer #{auth_token}" if auth_token && !auth_token.empty?
383
+
384
+ # Convert kubeclient SSL options to Faye::WebSocket/EventMachine TLS options
385
+ # Use provided ssl_options if available (to preserve :ca_file), otherwise use kube_client's
386
+ ssl_opts = @ssl_options || @kube_client.ssl_options
387
+ tls_options = convert_ssl_options_to_tls(ssl_opts)
388
+
389
+ retries.times do |_i|
390
+ return nil if client_socket.closed?
391
+
392
+ @logger.debug("Opening WebSocket to VMI '#{@vmi_name}' port #{@target_port}")
393
+
394
+ connection_status_q = Queue.new
395
+
396
+ EventMachine.schedule do
397
+ protocols = [PLAIN_STREAM_PROTOCOL]
398
+ ws = Faye::WebSocket::Client.new(url, protocols,
399
+ headers: headers,
400
+ tls: tls_options,
401
+ ping: WEBSOCKET_PING_INTERVAL)
402
+
403
+ ws.on :open do |_event|
404
+ @logger.debug("WebSocket open to VMI '#{@vmi_name}' (protocol: #{ws.protocol || 'none'})")
405
+ connection_status_q.push(ws)
406
+ end
407
+
408
+ # This handler now attempts to parse the HTTP response body on a 500 error
409
+ # to provide a more specific reason for the failure.
410
+ ws.on :close do |event|
411
+ if event.code == 1000 # Normal closure
412
+ @logger.debug('WebSocket connection closed normally.')
413
+ else
414
+ err_msg = "WebSocket closed unexpectedly. Code: #{event.code}, Reason: #{event.reason}"
415
+
416
+ # Check for the HTTP response object within the close event,
417
+ # which faye-websocket provides on handshake failure.
418
+ if event.instance_variable_defined?(:@driver) && event.driver.instance_variable_defined?(:@http)
419
+ http_response = event.driver.instance_variable_get(:@http)
420
+ if http_response && http_response.code == 500 && http_response.body
421
+ begin
422
+ error_body = JSON.parse(http_response.body)
423
+ if error_body['message']
424
+ # This is the actual root cause from the server.
425
+ err_msg = "Server returned 500 Internal Server Error: #{error_body['message']}"
426
+ end
427
+ rescue JSON::ParserError
428
+ # Body was not valid JSON, stick with the original error.
429
+ end
430
+ end
431
+ end
432
+
433
+ @logger.warn(err_msg)
434
+ connection_status_q.push(RuntimeError.new(err_msg))
435
+ end
436
+ end
437
+ rescue StandardError => e
438
+ err_msg = "Failed to establish WebSocket connection: #{e.class}: #{e.message}"
439
+ @logger.error(err_msg)
440
+ connection_status_q.push(RuntimeError.new(err_msg))
441
+ end
442
+
443
+ result = connection_status_q.pop
444
+
445
+ return result if result.is_a?(Faye::WebSocket::Client)
446
+
447
+ unless retries == 1
448
+ @logger.warn("WebSocket connect attempt failed. Retrying in #{delay} seconds...")
449
+ sleep delay
450
+ end
451
+ end
452
+ nil # All retries failed.
453
+ end
454
+
455
+ # Proxies data in both directions between the client and the WebSocket.
456
+ # @param client_socket [TCPSocket] The socket for the local client.
457
+ # @param websocket [Faye::WebSocket::Client] The connected WebSocket.
458
+ def proxy_traffic(client_socket, websocket)
459
+ use_channels = (websocket.protocol == STREAM_PROTOCOL)
460
+ if use_channels
461
+ @logger.debug("Using multiplexed stream protocol: #{STREAM_PROTOCOL}")
462
+ else
463
+ @logger.debug("Using raw stream protocol (negotiated: '#{websocket.protocol || 'none'}')")
464
+ end
465
+
466
+ # Mutex to synchronize access to client_socket from multiple threads
467
+ socket_mutex = Mutex.new
468
+
469
+ # No client-silence timeout: net-ssh defers keepalive while output is
470
+ # streaming server->client, so long-running commands legitimately go
471
+ # minutes without any client->ws bytes. We rely on EOF/RST from the OS
472
+ # when the SSH client dies, and on the WS ping above to detect the
473
+ # upstream half going away.
474
+ to_ws = Thread.new do
475
+ loop do
476
+ data = client_socket.readpartial(4096)
477
+ if use_channels
478
+ websocket.send(DATA_CHANNEL + data)
479
+ else
480
+ websocket.send(data)
481
+ end
482
+ end
483
+ rescue Errno::ECONNRESET, IOError => e
484
+ if e.is_a?(EOFError)
485
+ @logger.debug('Client connection closed cleanly (EOF). Shutting down proxy.')
486
+ else
487
+ @logger.debug("Client socket closed (#{e.class}: #{e.message}). Shutting down proxy.")
488
+ end
489
+ begin
490
+ Timeout.timeout(5) do
491
+ websocket.close if websocket.ready_state == Faye::WebSocket::Client::OPEN
492
+ end
493
+ rescue Timeout::Error
494
+ @logger.warn('Timeout while closing WebSocket. It may have already been closed.')
495
+ rescue StandardError => e
496
+ @logger.warn("Failed to close WebSocket properly: #{e.message}")
497
+ nil
498
+ end
499
+ end
500
+
501
+ websocket.on :message do |event|
502
+ payload = event.data
503
+
504
+ # Synchronize writes to client_socket
505
+ socket_mutex.synchronize do
506
+ if use_channels
507
+ channel = payload[0]
508
+ case channel
509
+ when DATA_CHANNEL
510
+ client_socket.write(payload[1..])
511
+ when ERROR_CHANNEL
512
+ report_error(RuntimeError.new("Received error from server: #{payload[1..].inspect}"))
513
+ else
514
+ @logger.warn("Received message on unknown channel: #{channel.inspect}. Treating as raw data.")
515
+ client_socket.write(payload)
516
+ end
517
+ else
518
+ client_socket.write(payload)
519
+ end
520
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
521
+ @logger.debug("Failed to write to client socket: #{e.class}: #{e.message}")
522
+ # Socket is closed or broken, ignore the error
523
+ end
524
+ end
525
+
526
+ websocket.on :close do |event|
527
+ @logger.debug("WebSocket connection closed. Code: #{event.code}, Reason: #{event.reason}")
528
+ socket_mutex.synchronize do
529
+ client_socket.close
530
+ rescue StandardError
531
+ nil
532
+ end
533
+ end
534
+
535
+ to_ws.join
536
+ end
537
+
538
+ # Centralized error reporting.
539
+ def report_error(error, context = nil)
540
+ log_message = "ERROR: #{context}: " if context
541
+ log_message ||= 'ERROR: '
542
+ log_message += "#{error.class}: #{error.message}"
543
+ # Backtrace may be nil for manually constructed errors
544
+ log_message += "\n#{error.backtrace.join("\n")}" if error.backtrace
545
+ @logger.error(log_message)
546
+ @on_error&.call(error)
547
+ end
548
+
549
+ # Manages state transitions with thread safety.
550
+ # rubocop:disable Naming/PredicateMethod
551
+ def state_transition_to(new_state)
552
+ @mutex.synchronize do
553
+ case new_state
554
+ when :starting
555
+ return false unless %i[new stopped].include?(@state)
556
+ when :running
557
+ return false unless [:starting].include?(@state)
558
+ when :stopping
559
+ return false unless %i[running error].include?(@state)
560
+ when :stopped
561
+ return false unless [:stopping].include?(@state)
562
+ end
563
+ @state = new_state
564
+ end
565
+ true
566
+ end
567
+ # rubocop:enable Naming/PredicateMethod
568
+
569
+ # Convert kubeclient SSL options to Faye::WebSocket/EventMachine TLS options.
570
+ # @param ssl_options [Hash] The SSL options from kubeclient
571
+ # @return [Hash] TLS options compatible with Faye::WebSocket::Client
572
+ def convert_ssl_options_to_tls(ssl_options)
573
+ return {} if ssl_options.nil? || ssl_options.empty?
574
+
575
+ tls_options = {}
576
+
577
+ # Faye::WebSocket (built on EventMachine) supports custom CA certificates via the
578
+ # :root_cert_file option. This enables proper SSL verification for in-cluster connections
579
+ # with self-signed certificates.
580
+ #
581
+ # Note: :cert_chain_file is for client certificates, :root_cert_file is for CA certs
582
+
583
+ # Pass CA certificate file for server verification
584
+ if ssl_options[:ca_file]
585
+ tls_options[:root_cert_file] = ssl_options[:ca_file]
586
+ @logger.debug("Using CA certificate: #{ssl_options[:ca_file]}")
587
+
588
+ # Explicitly enable verification when we have a CA cert, unless explicitly disabled
589
+ tls_options[:verify_peer] = true unless ssl_options.key?(:verify_ssl) && !ssl_options[:verify_ssl]
590
+ end
591
+
592
+ # Handle explicit SSL verification setting
593
+ # Faye::WebSocket uses :verify_peer (boolean), while kubeclient uses :verify_ssl (may be OpenSSL constant)
594
+ if ssl_options.key?(:verify_ssl)
595
+ verify_value = ssl_options[:verify_ssl]
596
+ # Convert OpenSSL constants to boolean
597
+ # OpenSSL::SSL::VERIFY_NONE = 0, OpenSSL::SSL::VERIFY_PEER = 1
598
+ tls_options[:verify_peer] = if verify_value.is_a?(Integer)
599
+ (verify_value != 0)
600
+ else
601
+ verify_value ? true : false
602
+ end
603
+ @logger.debug("SSL verification: #{[false, 0].include?(verify_value) ? 'disabled' : 'enabled'}")
604
+ end
605
+
606
+ # Pass through client certificates if present (for mutual TLS authentication)
607
+ tls_options[:private_key_file] = ssl_options[:client_key] if ssl_options[:client_key]
608
+ tls_options[:cert_chain_file] = ssl_options[:client_cert] if ssl_options[:client_cert]
609
+
610
+ tls_options
611
+ end
612
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BeakerKubevirt
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,15 @@
1
+ HOSTS:
2
+ test-el-vm:
3
+ platform: redhat-9-x86_64
4
+ hypervisor: kubevirt
5
+ kubevirt_vm_image: https://repo.almalinux.org/almalinux/9.6/cloud/x86_64/images/AlmaLinux-9-GenericCloud-9.6-20250522.x86_64.qcow2
6
+ kubevirt_memory: 2Gi
7
+ kubevirt_cpus: 2
8
+ kubevirt_network_mode: multus
9
+ networks:
10
+ - name: multus-network
11
+ multus_network_name: 'my-network'
12
+ kubevirt_disk_size: 21G
13
+ CONFIG:
14
+ namespace: beaker-tests # moved to global config
15
+ log_level: debug
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'beaker-rspec'
4
+ require 'beaker/hypervisor/kubevirt'
5
+
6
+ describe Beaker::Hypervisor::Kubevirt do
7
+ it 'can run a command' do
8
+ result = on default, 'echo hello'
9
+ expect(result.stdout).to match(/hello/)
10
+ end
11
+ end