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,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,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
|