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,593 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kubeclient'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require 'base64'
|
|
7
|
+
|
|
8
|
+
module Beaker
|
|
9
|
+
# Helper class for KubeVirt operations
|
|
10
|
+
class KubevirtHelper
|
|
11
|
+
attr_reader :namespace, :options, :k8s_client, :kubevirt_client, :cdi_client
|
|
12
|
+
|
|
13
|
+
def initialize(options)
|
|
14
|
+
@options = options
|
|
15
|
+
@namespace = options[:namespace] || 'default'
|
|
16
|
+
raw_kubeconfig = options[:kubeconfig] || ENV['KUBECONFIG'] || File.join(Dir.home, '.kube', 'config')
|
|
17
|
+
@kubeconfig_path = File.expand_path(raw_kubeconfig)
|
|
18
|
+
@kubecontext = options[:kubecontext] || ENV.fetch('KUBECONTEXT', nil)
|
|
19
|
+
@logger = options[:logger]
|
|
20
|
+
|
|
21
|
+
# Store original SSL options for port forwarding (before kubeclient processes them)
|
|
22
|
+
@original_ssl_options = nil
|
|
23
|
+
|
|
24
|
+
# Keep temp files alive to prevent garbage collection
|
|
25
|
+
@temp_files = []
|
|
26
|
+
|
|
27
|
+
# Allow injection of clients for testing
|
|
28
|
+
@k8s_client = options[:k8s_client]
|
|
29
|
+
@kubevirt_client = options[:kubevirt_client]
|
|
30
|
+
@cdi_client = options[:cdi_client]
|
|
31
|
+
|
|
32
|
+
# Only setup clients if not provided (for testing)
|
|
33
|
+
return if @k8s_client && @kubevirt_client
|
|
34
|
+
|
|
35
|
+
# Manually extract SSL options with CA file by parsing kubeconfig directly
|
|
36
|
+
# (Kubeclient context doesn't expose the raw CA file path/data)
|
|
37
|
+
@original_ssl_options = extract_ssl_from_kubeconfig
|
|
38
|
+
setup_kubernetes_client
|
|
39
|
+
setup_kubevirt_client
|
|
40
|
+
setup_cdi_client unless @cdi_client
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Create a virtual machine
|
|
45
|
+
# @param [Hash] vm_spec The VM specification
|
|
46
|
+
def create_vm(vm_spec)
|
|
47
|
+
# Convert all keys to symbols recursively
|
|
48
|
+
vm_spec_sym = symbolize_keys(vm_spec)
|
|
49
|
+
@kubevirt_client.create_virtual_machine(vm_spec_sym)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Create a secret
|
|
54
|
+
# @param [Hash] secret_spec The secret specification
|
|
55
|
+
def create_secret(secret_spec)
|
|
56
|
+
# Convert all keys to symbols recursively
|
|
57
|
+
secret_spec_sym = symbolize_keys(secret_spec)
|
|
58
|
+
@k8s_client.create_secret(secret_spec_sym)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# Get a virtual machine
|
|
63
|
+
# @param [String] vm_name The VM name
|
|
64
|
+
# @return [Hash] VM object
|
|
65
|
+
def get_vm(vm_name)
|
|
66
|
+
@kubevirt_client.get_virtual_machine(vm_name, @namespace)
|
|
67
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# Get a virtual machine instance
|
|
73
|
+
# @param [String] vmi_name The VMI name
|
|
74
|
+
# @return [Hash] VMI object
|
|
75
|
+
def get_vmi(vmi_name)
|
|
76
|
+
@kubevirt_client.get_virtual_machine_instance(vmi_name, @namespace)
|
|
77
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# Get the virt-launcher pod backing a VMI.
|
|
83
|
+
# @param [String] vmi_name The VMI name (== VM name)
|
|
84
|
+
# @return [Hash, nil] pod object, or nil if not yet scheduled
|
|
85
|
+
def get_virt_launcher_pod(vmi_name)
|
|
86
|
+
pods = @k8s_client.get_pods(namespace: @namespace,
|
|
87
|
+
label_selector: "kubevirt.io/vm=#{vmi_name}")
|
|
88
|
+
pods.first
|
|
89
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Delete a virtual machine
|
|
95
|
+
# @param [String] vm_name The VM name
|
|
96
|
+
def delete_vm(vm_name)
|
|
97
|
+
@kubevirt_client.delete_virtual_machine(vm_name, @namespace)
|
|
98
|
+
@logger.debug("Deleted VM #{vm_name}")
|
|
99
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
100
|
+
@logger.debug("VM #{vm_name} not found during deletion")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
##
|
|
104
|
+
# Cleanup VMs created by this test group
|
|
105
|
+
# @param [String] test_group_identifier The identifier for the test group
|
|
106
|
+
def cleanup_vms(test_group_identifier)
|
|
107
|
+
@logger.info("Cleaning up VMs for test group: #{test_group_identifier}")
|
|
108
|
+
label_selector = "beaker/test-group=#{test_group_identifier}"
|
|
109
|
+
@logger.debug("Using label selector: #{label_selector}")
|
|
110
|
+
|
|
111
|
+
vms = @kubevirt_client.get_virtual_machines(namespace: @namespace,
|
|
112
|
+
label_selector: label_selector)
|
|
113
|
+
@logger.info("Found #{vms.length} VM(s) with label beaker/test-group=#{test_group_identifier}")
|
|
114
|
+
|
|
115
|
+
vms.each do |vm|
|
|
116
|
+
# Extract VM name - access hash-style to avoid RSpec metadata collision
|
|
117
|
+
vm_metadata = vm[:metadata] || vm['metadata']
|
|
118
|
+
vm_name = vm_metadata[:name] || vm_metadata['name'] if vm_metadata
|
|
119
|
+
|
|
120
|
+
unless vm_name && !vm_name.empty?
|
|
121
|
+
@logger.error('Cannot delete VM with empty or nil name.')
|
|
122
|
+
@logger.error("VM structure: #{vm.inspect[0..1000]}")
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
vm_labels = vm_metadata[:labels] || vm_metadata['labels'] if vm_metadata
|
|
127
|
+
@logger.debug("Deleting VM #{vm_name} with labels: #{vm_labels.inspect}")
|
|
128
|
+
@kubevirt_client.delete_virtual_machine(vm_name, @namespace)
|
|
129
|
+
@logger.info("Deleted VM #{vm_name}")
|
|
130
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
131
|
+
@logger.debug("VM #{vm_name} not found during cleanup")
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
@logger.error("Error deleting VM #{vm_name}: #{e.message}")
|
|
134
|
+
@logger.error("Backtrace: #{e.backtrace[0..5].join("\n")}")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
##
|
|
139
|
+
# Cleanup secrets associated with a test group
|
|
140
|
+
# @param [String] test_group_identifier The identifier for the test group
|
|
141
|
+
def cleanup_secrets(test_group_identifier)
|
|
142
|
+
@logger.info("Cleaning up secrets for test group: #{test_group_identifier}")
|
|
143
|
+
label_selector = "beaker/test-group=#{test_group_identifier}"
|
|
144
|
+
@logger.debug("Using label selector: #{label_selector}")
|
|
145
|
+
|
|
146
|
+
secrets = @k8s_client.get_secrets(namespace: @namespace,
|
|
147
|
+
label_selector: label_selector)
|
|
148
|
+
@logger.info("Found #{secrets.length} secret(s) with label beaker/test-group=#{test_group_identifier}")
|
|
149
|
+
|
|
150
|
+
secrets.each do |secret|
|
|
151
|
+
# Extract secret name - access hash-style to avoid RSpec metadata collision
|
|
152
|
+
secret_metadata = secret[:metadata] || secret['metadata']
|
|
153
|
+
secret_name = secret_metadata[:name] || secret_metadata['name'] if secret_metadata
|
|
154
|
+
|
|
155
|
+
unless secret_name && !secret_name.empty?
|
|
156
|
+
@logger.error('Cannot delete secret with empty or nil name.')
|
|
157
|
+
@logger.error("Secret structure: #{secret.inspect[0..1000]}")
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
secret_labels = secret_metadata[:labels] || secret_metadata['labels'] if secret_metadata
|
|
162
|
+
@logger.debug("Deleting secret #{secret_name} with labels: #{secret_labels.inspect}")
|
|
163
|
+
@k8s_client.delete_secret(secret_name, @namespace)
|
|
164
|
+
@logger.info("Deleted secret #{secret_name}")
|
|
165
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
166
|
+
@logger.debug("Secret #{secret_name} not found during cleanup")
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
@logger.error("Error deleting secret #{secret_name}: #{e.message}")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
##
|
|
173
|
+
# Cleanup services associated with a test group
|
|
174
|
+
# @param [String] test_group_identifier The identifier for the test group
|
|
175
|
+
def cleanup_services(test_group_identifier)
|
|
176
|
+
@logger.info("Cleaning up services for test group: #{test_group_identifier}")
|
|
177
|
+
label_selector = "beaker/test-group=#{test_group_identifier}"
|
|
178
|
+
@logger.debug("Using label selector: #{label_selector}")
|
|
179
|
+
|
|
180
|
+
services = @k8s_client.get_services(namespace: @namespace,
|
|
181
|
+
label_selector: label_selector)
|
|
182
|
+
@logger.info("Found #{services.length} service(s) with label beaker/test-group=#{test_group_identifier}")
|
|
183
|
+
|
|
184
|
+
services.each do |service|
|
|
185
|
+
# Extract service name - access hash-style to avoid RSpec metadata collision
|
|
186
|
+
service_metadata = service[:metadata] || service['metadata']
|
|
187
|
+
service_name = service_metadata[:name] || service_metadata['name'] if service_metadata
|
|
188
|
+
|
|
189
|
+
unless service_name && !service_name.empty?
|
|
190
|
+
@logger.error('Cannot delete service with empty or nil name.')
|
|
191
|
+
@logger.error("Service structure: #{service.inspect[0..1000]}")
|
|
192
|
+
next
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
service_labels = service_metadata[:labels] || service_metadata['labels'] if service_metadata
|
|
196
|
+
@logger.debug("Deleting service #{service_name} with labels: #{service_labels.inspect}")
|
|
197
|
+
@k8s_client.delete_service(service_name, @namespace)
|
|
198
|
+
@logger.info("Deleted service #{service_name}")
|
|
199
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
200
|
+
@logger.debug("Service #{service_name} not found during cleanup")
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
@logger.error("Error deleting service #{service_name}: #{e.message}")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
##
|
|
207
|
+
# Cleanup DataVolumes associated with a test group. No-op if CDI isn't
|
|
208
|
+
# available on the cluster (the @cdi_client stays nil in that case).
|
|
209
|
+
# @param [String] test_group_identifier The identifier for the test group
|
|
210
|
+
def cleanup_data_volumes(test_group_identifier)
|
|
211
|
+
return unless @cdi_client
|
|
212
|
+
|
|
213
|
+
@logger.info("Cleaning up DataVolumes for test group: #{test_group_identifier}")
|
|
214
|
+
label_selector = "beaker/test-group=#{test_group_identifier}"
|
|
215
|
+
|
|
216
|
+
begin
|
|
217
|
+
dvs = @cdi_client.get_data_volumes(namespace: @namespace, label_selector: label_selector)
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
@logger.warn("Failed to list DataVolumes for cleanup: #{e.class}: #{e.message}")
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
@logger.info("Found #{dvs.length} DataVolume(s) with label #{label_selector}")
|
|
224
|
+
|
|
225
|
+
dvs.each do |dv|
|
|
226
|
+
md = dv[:metadata] || dv['metadata']
|
|
227
|
+
dv_name = md[:name] || md['name'] if md
|
|
228
|
+
unless dv_name && !dv_name.empty?
|
|
229
|
+
@logger.error("Skipping DataVolume with missing name: #{dv.inspect[0, 200]}")
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
@cdi_client.delete_data_volume(dv_name, @namespace)
|
|
234
|
+
@logger.info("Deleted DataVolume #{dv_name}")
|
|
235
|
+
rescue Kubeclient::ResourceNotFoundError
|
|
236
|
+
@logger.debug("DataVolume #{dv_name} not found during cleanup")
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
@logger.error("Error deleting DataVolume #{dv_name}: #{e.message}")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
##
|
|
243
|
+
# Setup port forwarding for a VM
|
|
244
|
+
# @param [String] vm_name The VM name
|
|
245
|
+
# @param [Integer] vm_port The VM port to forward
|
|
246
|
+
# @param [Integer] local_port The local port to forward to
|
|
247
|
+
# @return [Process] The port-forward process
|
|
248
|
+
def setup_port_forward(vm_name, vm_port, local_port)
|
|
249
|
+
require 'beaker/hypervisor/port_forward'
|
|
250
|
+
forwarder = KubeVirtPortForwarder.new(
|
|
251
|
+
kube_client: @kubevirt_client,
|
|
252
|
+
namespace: @namespace,
|
|
253
|
+
vmi_name: vm_name,
|
|
254
|
+
target_port: vm_port,
|
|
255
|
+
local_port: local_port,
|
|
256
|
+
logger: @logger,
|
|
257
|
+
on_error: method(:forwarder_error_handler),
|
|
258
|
+
ssl_options: @original_ssl_options,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Start the port forwarder in a background thread
|
|
262
|
+
forwarder.start
|
|
263
|
+
|
|
264
|
+
# Check if the forwarder started correctly.
|
|
265
|
+
return forwarder if forwarder.state == :running
|
|
266
|
+
|
|
267
|
+
@logger.error("Port forwarder failed to start for VM #{vm_name} on port #{vm_port}")
|
|
268
|
+
raise "Port forwarder failed to start for VM #{vm_name} on port #{vm_port}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def forwarder_error_handler(error)
|
|
272
|
+
@logger.error("Port forwarder error: #{error.message}")
|
|
273
|
+
# Optionally, you can implement retry logic or cleanup here
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
##
|
|
277
|
+
# Create a NodePort service for SSH access
|
|
278
|
+
# @param [String] vm_name The VM name
|
|
279
|
+
# @param [String] service_name The service name
|
|
280
|
+
# @param [String, nil] test_group_identifier Test-group identifier applied as
|
|
281
|
+
# the +beaker/test-group+ label so +cleanup_services+ can match the
|
|
282
|
+
# service. When +nil+, only the +beaker/vm+ label is set (legacy behavior).
|
|
283
|
+
# @return [Hash] Service object
|
|
284
|
+
def create_nodeport_service(vm_name, service_name, test_group_identifier = nil)
|
|
285
|
+
labels = { 'beaker/vm' => vm_name }
|
|
286
|
+
labels['beaker/test-group'] = test_group_identifier if test_group_identifier
|
|
287
|
+
|
|
288
|
+
service_spec = {
|
|
289
|
+
'apiVersion' => 'v1',
|
|
290
|
+
'kind' => 'Service',
|
|
291
|
+
'metadata' => {
|
|
292
|
+
'name' => service_name,
|
|
293
|
+
'namespace' => @namespace,
|
|
294
|
+
'labels' => labels,
|
|
295
|
+
},
|
|
296
|
+
'spec' => {
|
|
297
|
+
'type' => 'NodePort',
|
|
298
|
+
'selector' => {
|
|
299
|
+
'kubevirt.io/vm' => vm_name,
|
|
300
|
+
},
|
|
301
|
+
'ports' => [
|
|
302
|
+
{
|
|
303
|
+
'name' => 'ssh',
|
|
304
|
+
'port' => 22,
|
|
305
|
+
'targetPort' => 22,
|
|
306
|
+
'protocol' => 'TCP',
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
service_spec_sym = symbolize_keys(service_spec)
|
|
313
|
+
@k8s_client.create_service(service_spec_sym)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
##
|
|
317
|
+
# Unlink the kubeconfig-derived cert/key tempfiles kept alive for the
|
|
318
|
+
# process lifetime. Safe to call after all API operations that need the
|
|
319
|
+
# files have completed.
|
|
320
|
+
def cleanup_temp_files
|
|
321
|
+
return unless @temp_files
|
|
322
|
+
|
|
323
|
+
@temp_files.each do |file|
|
|
324
|
+
path = file.path
|
|
325
|
+
file.close unless file.closed?
|
|
326
|
+
file.unlink
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
@logger&.warn("Failed to unlink temp file #{path}: #{e.message}")
|
|
329
|
+
end
|
|
330
|
+
@temp_files.clear
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
##
|
|
334
|
+
# Get a cluster node IP address
|
|
335
|
+
# @return [String] Node IP address
|
|
336
|
+
def node_ip
|
|
337
|
+
nodes = @k8s_client.get_nodes
|
|
338
|
+
node = nodes.first
|
|
339
|
+
|
|
340
|
+
# Try to get external IP first, fallback to internal IP
|
|
341
|
+
addresses = node.dig('status', 'addresses') || []
|
|
342
|
+
external_ip = addresses.find { |addr| addr['type'] == 'ExternalIP' }
|
|
343
|
+
internal_ip = addresses.find { |addr| addr['type'] == 'InternalIP' }
|
|
344
|
+
|
|
345
|
+
if external_ip
|
|
346
|
+
external_ip['address']
|
|
347
|
+
elsif internal_ip
|
|
348
|
+
internal_ip['address']
|
|
349
|
+
else
|
|
350
|
+
raise 'Could not determine node IP address'
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
private
|
|
355
|
+
|
|
356
|
+
##
|
|
357
|
+
# Setup Kubernetes API client
|
|
358
|
+
def setup_kubernetes_client
|
|
359
|
+
config = Kubeclient::Config.new(load_kubeconfig, File.dirname(@kubeconfig_path))
|
|
360
|
+
context_config = config.context(@kubecontext)
|
|
361
|
+
|
|
362
|
+
@k8s_client = Kubeclient::Client.new(
|
|
363
|
+
context_config.api_endpoint,
|
|
364
|
+
context_config.api_version,
|
|
365
|
+
ssl_options: context_config.ssl_options,
|
|
366
|
+
auth_options: context_config.auth_options,
|
|
367
|
+
)
|
|
368
|
+
rescue StandardError => e
|
|
369
|
+
# For testing or when Kubeclient can't parse, fall back to manual parsing
|
|
370
|
+
@logger&.warn("Failed to use Kubeclient::Config, falling back to manual parsing: #{e.message}")
|
|
371
|
+
setup_kubernetes_client_manual
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
##
|
|
375
|
+
# Setup KubeVirt API client
|
|
376
|
+
def setup_kubevirt_client
|
|
377
|
+
config = Kubeclient::Config.new(load_kubeconfig, File.dirname(@kubeconfig_path))
|
|
378
|
+
context_config = config.context(@kubecontext)
|
|
379
|
+
|
|
380
|
+
@kubevirt_client = Kubeclient::Client.new(
|
|
381
|
+
"#{context_config.api_endpoint}/apis/kubevirt.io",
|
|
382
|
+
context_config.api_version,
|
|
383
|
+
ssl_options: context_config.ssl_options,
|
|
384
|
+
auth_options: context_config.auth_options,
|
|
385
|
+
)
|
|
386
|
+
rescue StandardError => e
|
|
387
|
+
# For testing or when Kubeclient can't parse, fall back to manual parsing
|
|
388
|
+
@logger&.warn("Failed to use Kubeclient::Config, falling back to manual parsing: #{e.message}")
|
|
389
|
+
setup_kubevirt_client_manual
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
##
|
|
393
|
+
# Setup CDI (Containerized Data Importer) API client used for managing
|
|
394
|
+
# DataVolumes. Best-effort: clusters without CDI installed will still be
|
|
395
|
+
# able to provision container-disk / direct-PVC VMs. We probe the API
|
|
396
|
+
# group with a `discover` call so that clusters lacking CDI leave
|
|
397
|
+
# @cdi_client nil rather than appearing live and warning on every
|
|
398
|
+
# cleanup.
|
|
399
|
+
def setup_cdi_client
|
|
400
|
+
config = Kubeclient::Config.new(load_kubeconfig, File.dirname(@kubeconfig_path))
|
|
401
|
+
context_config = config.context(@kubecontext)
|
|
402
|
+
client = Kubeclient::Client.new(
|
|
403
|
+
"#{context_config.api_endpoint}/apis/cdi.kubevirt.io",
|
|
404
|
+
'v1beta1',
|
|
405
|
+
ssl_options: context_config.ssl_options,
|
|
406
|
+
auth_options: context_config.auth_options,
|
|
407
|
+
)
|
|
408
|
+
client.discover
|
|
409
|
+
@cdi_client = client
|
|
410
|
+
rescue StandardError => e
|
|
411
|
+
@logger&.debug("CDI client setup skipped: #{e.class}: #{e.message}")
|
|
412
|
+
@cdi_client = nil
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
##
|
|
416
|
+
# Setup Kubernetes API client using manual kubeconfig parsing
|
|
417
|
+
def setup_kubernetes_client_manual
|
|
418
|
+
config = load_kubeconfig
|
|
419
|
+
context_config = get_context_config(config)
|
|
420
|
+
ssl_options = setup_ssl_options(context_config)
|
|
421
|
+
auth_options = setup_auth_options(context_config)
|
|
422
|
+
|
|
423
|
+
# Store original SSL options before kubeclient processes them
|
|
424
|
+
@original_ssl_options = ssl_options.dup
|
|
425
|
+
|
|
426
|
+
@k8s_client = Kubeclient::Client.new(
|
|
427
|
+
context_config['cluster']['server'],
|
|
428
|
+
'v1',
|
|
429
|
+
ssl_options: ssl_options,
|
|
430
|
+
auth_options: auth_options,
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
##
|
|
435
|
+
# Setup KubeVirt API client using manual kubeconfig parsing
|
|
436
|
+
def setup_kubevirt_client_manual
|
|
437
|
+
config = load_kubeconfig
|
|
438
|
+
context_config = get_context_config(config)
|
|
439
|
+
ssl_options = setup_ssl_options(context_config)
|
|
440
|
+
auth_options = setup_auth_options(context_config)
|
|
441
|
+
|
|
442
|
+
# Store original SSL options before kubeclient processes them
|
|
443
|
+
@original_ssl_options = ssl_options.dup
|
|
444
|
+
|
|
445
|
+
kubevirt_endpoint = "#{context_config['cluster']['server']}/apis/kubevirt.io"
|
|
446
|
+
@kubevirt_client = Kubeclient::Client.new(
|
|
447
|
+
kubevirt_endpoint,
|
|
448
|
+
'v1',
|
|
449
|
+
ssl_options: ssl_options,
|
|
450
|
+
auth_options: auth_options,
|
|
451
|
+
)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
##
|
|
455
|
+
# Load kubeconfig file
|
|
456
|
+
# @return [Hash] Parsed kubeconfig
|
|
457
|
+
def load_kubeconfig
|
|
458
|
+
return @kubeconfig_data if @kubeconfig_data
|
|
459
|
+
raise "Kubeconfig file not found: #{@kubeconfig_path}" unless File.exist?(@kubeconfig_path)
|
|
460
|
+
raise "Kubeconfig path is not a regular file: #{@kubeconfig_path}" unless File.file?(@kubeconfig_path)
|
|
461
|
+
|
|
462
|
+
@logger&.warn("kubeconfig #{@kubeconfig_path} is a symlink to #{File.realpath(@kubeconfig_path)}") if File.symlink?(@kubeconfig_path)
|
|
463
|
+
|
|
464
|
+
@kubeconfig_data = YAML.safe_load_file(@kubeconfig_path)
|
|
465
|
+
@kubeconfig_data
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
##
|
|
469
|
+
# Get context configuration from kubeconfig
|
|
470
|
+
# @param [Hash] config The kubeconfig
|
|
471
|
+
# @return [Hash] Context configuration
|
|
472
|
+
def get_context_config(config)
|
|
473
|
+
current_context = @kubecontext || config['current-context']
|
|
474
|
+
raise 'No current context specified' unless current_context
|
|
475
|
+
|
|
476
|
+
context = config['contexts'].find { |ctx| ctx['name'] == current_context }
|
|
477
|
+
raise "Context '#{current_context}' not found" unless context
|
|
478
|
+
|
|
479
|
+
cluster_name = context.dig('context', 'cluster')
|
|
480
|
+
user_name = context.dig('context', 'user')
|
|
481
|
+
|
|
482
|
+
cluster = config['clusters'].find { |c| c['name'] == cluster_name }
|
|
483
|
+
user = config['users'].find { |u| u['name'] == user_name }
|
|
484
|
+
|
|
485
|
+
raise "Cluster '#{cluster_name}' not found" unless cluster
|
|
486
|
+
raise "User '#{user_name}' not found" unless user
|
|
487
|
+
|
|
488
|
+
{
|
|
489
|
+
'cluster' => cluster['cluster'],
|
|
490
|
+
'user' => user['user'],
|
|
491
|
+
'namespace' => context.dig('context', 'namespace') || @namespace,
|
|
492
|
+
}
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
##
|
|
496
|
+
# Write content to a temporary file
|
|
497
|
+
# @param [String] prefix File prefix
|
|
498
|
+
# @param [String] content File content
|
|
499
|
+
# @return [String] Path to temporary file
|
|
500
|
+
def write_temp_file(prefix, content)
|
|
501
|
+
file = Tempfile.new([prefix, '.pem'])
|
|
502
|
+
file.binmode
|
|
503
|
+
file.write(content)
|
|
504
|
+
file.flush
|
|
505
|
+
file.close
|
|
506
|
+
# Keep reference to prevent garbage collection and file deletion
|
|
507
|
+
@temp_files << file
|
|
508
|
+
@logger&.debug("Created temp file: #{file.path} (#{content.bytesize} bytes)")
|
|
509
|
+
file.path
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
##
|
|
513
|
+
# Recursively convert hash keys to symbols
|
|
514
|
+
def symbolize_keys(obj)
|
|
515
|
+
case obj
|
|
516
|
+
when Hash
|
|
517
|
+
obj.each_with_object({}) do |(k, v), memo|
|
|
518
|
+
memo[k.to_sym] = symbolize_keys(v)
|
|
519
|
+
end
|
|
520
|
+
when Array
|
|
521
|
+
obj.map { |v| symbolize_keys(v) }
|
|
522
|
+
else
|
|
523
|
+
obj
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
##
|
|
528
|
+
# Extract SSL options directly from kubeconfig file
|
|
529
|
+
# This preserves the :ca_file path before kubeclient converts it to :cert_store
|
|
530
|
+
# @return [Hash] SSL options with :ca_file preserved
|
|
531
|
+
def extract_ssl_from_kubeconfig
|
|
532
|
+
config = load_kubeconfig
|
|
533
|
+
context_config = get_context_config(config)
|
|
534
|
+
ssl_options = setup_ssl_options(context_config)
|
|
535
|
+
@logger&.debug("Extracted SSL options from kubeconfig: #{ssl_options.keys.join(', ')}")
|
|
536
|
+
ssl_options
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
##
|
|
540
|
+
# Setup SSL options from context config
|
|
541
|
+
# @param [Hash] context_config The context configuration
|
|
542
|
+
# @return [Hash] SSL options for Kubeclient
|
|
543
|
+
def setup_ssl_options(context_config)
|
|
544
|
+
ssl_options = {}
|
|
545
|
+
cluster_config = context_config['cluster']
|
|
546
|
+
|
|
547
|
+
if cluster_config['certificate-authority-data']
|
|
548
|
+
ca_cert = Base64.strict_decode64(cluster_config['certificate-authority-data'])
|
|
549
|
+
ca_file_path = write_temp_file('ca-cert', ca_cert)
|
|
550
|
+
ssl_options[:ca_file] = ca_file_path
|
|
551
|
+
elsif cluster_config['certificate-authority']
|
|
552
|
+
ssl_options[:ca_file] = cluster_config['certificate-authority']
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
ssl_options[:verify_ssl] = false if cluster_config['insecure-skip-tls-verify']
|
|
556
|
+
|
|
557
|
+
ssl_options
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
##
|
|
561
|
+
# Setup auth options from context config
|
|
562
|
+
# @param [Hash] context_config The context configuration
|
|
563
|
+
# @return [Hash] Auth options for Kubeclient
|
|
564
|
+
def setup_auth_options(context_config)
|
|
565
|
+
auth_options = {}
|
|
566
|
+
user_config = context_config['user']
|
|
567
|
+
|
|
568
|
+
if user_config['token']
|
|
569
|
+
auth_options[:bearer_token] = user_config['token']
|
|
570
|
+
elsif user_config['tokenFile']
|
|
571
|
+
token_file_path = user_config['tokenFile']
|
|
572
|
+
raise "Token file not found: #{token_file_path}" unless File.exist?(token_file_path)
|
|
573
|
+
|
|
574
|
+
auth_options[:bearer_token] = File.read(token_file_path).strip
|
|
575
|
+
|
|
576
|
+
elsif user_config['client-certificate-data'] && user_config['client-key-data']
|
|
577
|
+
client_cert = Base64.strict_decode64(user_config['client-certificate-data'])
|
|
578
|
+
client_key = Base64.strict_decode64(user_config['client-key-data'])
|
|
579
|
+
|
|
580
|
+
cert_file_path = write_temp_file('client-cert', client_cert)
|
|
581
|
+
key_file_path = write_temp_file('client-key', client_key)
|
|
582
|
+
|
|
583
|
+
auth_options[:client_cert] = cert_file_path
|
|
584
|
+
auth_options[:client_key] = key_file_path
|
|
585
|
+
elsif user_config['client-certificate'] && user_config['client-key']
|
|
586
|
+
auth_options[:client_cert] = user_config['client-certificate']
|
|
587
|
+
auth_options[:client_key] = user_config['client-key']
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
auth_options
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|