radfish 0.1.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/LICENSE +21 -0
- data/README.md +531 -0
- data/Rakefile +14 -0
- data/exe/radfish +7 -0
- data/lib/radfish/cli/base.rb +39 -0
- data/lib/radfish/cli.rb +759 -0
- data/lib/radfish/client.rb +142 -0
- data/lib/radfish/core/base_client.rb +126 -0
- data/lib/radfish/core/boot.rb +47 -0
- data/lib/radfish/core/jobs.rb +31 -0
- data/lib/radfish/core/power.rb +31 -0
- data/lib/radfish/core/session.rb +119 -0
- data/lib/radfish/core/storage.rb +23 -0
- data/lib/radfish/core/system.rb +39 -0
- data/lib/radfish/core/utility.rb +47 -0
- data/lib/radfish/core/virtual_media.rb +31 -0
- data/lib/radfish/vendor_detector.rb +199 -0
- data/lib/radfish/version.rb +5 -0
- data/lib/radfish.rb +89 -0
- data/radfish.gemspec +46 -0
- metadata +238 -0
data/lib/radfish/cli.rb
ADDED
@@ -0,0 +1,759 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thor'
|
4
|
+
require 'yaml'
|
5
|
+
require 'json'
|
6
|
+
require 'colorize'
|
7
|
+
require 'radfish'
|
8
|
+
|
9
|
+
module Radfish
|
10
|
+
class CLI < Thor
|
11
|
+
class_option :host, aliases: '-h', desc: 'BMC host/IP address (env: RADFISH_HOST)'
|
12
|
+
class_option :username, aliases: '-u', desc: 'BMC username (env: RADFISH_USERNAME)'
|
13
|
+
class_option :password, aliases: '-p', desc: 'BMC password (env: RADFISH_PASSWORD)'
|
14
|
+
class_option :config, aliases: '-c', desc: 'Config file path'
|
15
|
+
class_option :vendor, aliases: '-v', desc: 'Vendor (dell, supermicro, hpe, etc) - auto-detect if not specified (env: RADFISH_VENDOR)'
|
16
|
+
class_option :port, type: :numeric, default: 443, desc: 'BMC port (env: RADFISH_PORT)'
|
17
|
+
class_option :insecure, type: :boolean, default: true, desc: 'Skip SSL verification'
|
18
|
+
class_option :verbose, type: :boolean, default: false, desc: 'Enable verbose output'
|
19
|
+
class_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
|
20
|
+
|
21
|
+
desc "detect", "Detect the vendor of a BMC"
|
22
|
+
def detect
|
23
|
+
with_connection(skip_login: true) do |opts|
|
24
|
+
vendor = Radfish.detect_vendor(
|
25
|
+
host: opts[:host],
|
26
|
+
username: opts[:username],
|
27
|
+
password: opts[:password],
|
28
|
+
port: opts[:port],
|
29
|
+
verify_ssl: !opts[:insecure]
|
30
|
+
)
|
31
|
+
|
32
|
+
output_result({ vendor: vendor }, vendor ? "Detected vendor: #{vendor}" : "Could not detect vendor")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
desc "info", "Show BMC and system information"
|
37
|
+
def info
|
38
|
+
with_client do |client|
|
39
|
+
info = {
|
40
|
+
vendor: client.vendor_name,
|
41
|
+
adapter: client.adapter_class.name,
|
42
|
+
features: client.supported_features,
|
43
|
+
firmware_version: safe_call { client.get_firmware_version },
|
44
|
+
redfish_version: safe_call { client.redfish_version },
|
45
|
+
system: safe_call { client.system_info }
|
46
|
+
}
|
47
|
+
|
48
|
+
if options[:json]
|
49
|
+
puts JSON.pretty_generate(info)
|
50
|
+
else
|
51
|
+
puts "=== BMC Information ===".green
|
52
|
+
puts "Vendor: #{info[:vendor]}".cyan
|
53
|
+
puts "Adapter: #{info[:adapter]}".cyan
|
54
|
+
puts "Firmware: #{info[:firmware_version]}".cyan
|
55
|
+
puts "Redfish: #{info[:redfish_version]}".cyan
|
56
|
+
puts "Features: #{info[:features].join(', ')}".cyan
|
57
|
+
|
58
|
+
if info[:system]
|
59
|
+
puts "\n=== System Information ===".green
|
60
|
+
info[:system].each do |key, value|
|
61
|
+
puts "#{key.to_s.gsub('_', ' ').capitalize}: #{value}".cyan
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Power Commands
|
69
|
+
desc "power SUBCOMMAND", "Power management"
|
70
|
+
option :force, type: :boolean, default: false, desc: "Force power operation (skip graceful shutdown)"
|
71
|
+
def power(subcommand = 'status')
|
72
|
+
with_client do |client|
|
73
|
+
case subcommand
|
74
|
+
when 'status', 'state'
|
75
|
+
status = client.power_status
|
76
|
+
output_result({ power_status: status }, "Power Status: #{status}", status == 'On' ? :green : :yellow)
|
77
|
+
when 'on'
|
78
|
+
result = client.power_on
|
79
|
+
output_result({ success: result }, result ? "System powered on" : "Failed to power on")
|
80
|
+
when 'off'
|
81
|
+
result = client.power_off(force: options[:force])
|
82
|
+
mode = options[:force] ? "force" : "graceful"
|
83
|
+
output_result({ success: result }, result ? "System powered off (#{mode})" : "Failed to power off")
|
84
|
+
when 'force-off'
|
85
|
+
result = client.power_off(force: true)
|
86
|
+
output_result({ success: result }, result ? "System force powered off" : "Failed to force power off")
|
87
|
+
when 'restart', 'reboot'
|
88
|
+
result = client.power_restart(force: options[:force])
|
89
|
+
mode = options[:force] ? "force" : "graceful"
|
90
|
+
output_result({ success: result }, result ? "System restarting (#{mode})" : "Failed to restart")
|
91
|
+
when 'force-restart', 'force-reboot'
|
92
|
+
result = client.power_restart(force: true)
|
93
|
+
output_result({ success: result }, result ? "System force restarting" : "Failed to force restart")
|
94
|
+
when 'cycle'
|
95
|
+
result = client.power_cycle
|
96
|
+
output_result({ success: result }, result ? "Power cycle initiated" : "Failed to power cycle")
|
97
|
+
else
|
98
|
+
error "Unknown power command: #{subcommand}"
|
99
|
+
puts "Available: status, on, off, force-off, restart, force-restart, cycle"
|
100
|
+
puts "Use --force flag with 'off' or 'restart' to skip graceful shutdown"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# System Commands
|
106
|
+
desc "system SUBCOMMAND", "System inventory"
|
107
|
+
def system(subcommand = 'all')
|
108
|
+
with_client do |client|
|
109
|
+
case subcommand
|
110
|
+
when 'all'
|
111
|
+
data = {
|
112
|
+
cpus: safe_call { client.cpus },
|
113
|
+
memory: safe_call { client.memory },
|
114
|
+
nics: safe_call { client.nics },
|
115
|
+
fans: safe_call { client.fans },
|
116
|
+
temps: safe_call { client.temperatures },
|
117
|
+
psus: safe_call { client.psus }
|
118
|
+
}
|
119
|
+
|
120
|
+
if options[:json]
|
121
|
+
puts JSON.pretty_generate(data)
|
122
|
+
else
|
123
|
+
show_system_component('cpus', data[:cpus])
|
124
|
+
show_system_component('memory', data[:memory])
|
125
|
+
show_system_component('nics', data[:nics])
|
126
|
+
show_system_component('fans', data[:fans])
|
127
|
+
show_system_component('temps', data[:temps])
|
128
|
+
show_system_component('psus', data[:psus])
|
129
|
+
end
|
130
|
+
when 'cpus', 'cpu'
|
131
|
+
data = client.cpus
|
132
|
+
if options[:json]
|
133
|
+
puts JSON.pretty_generate(data)
|
134
|
+
else
|
135
|
+
show_system_component('cpus', data)
|
136
|
+
end
|
137
|
+
when 'memory', 'mem', 'ram'
|
138
|
+
data = client.memory
|
139
|
+
if options[:json]
|
140
|
+
puts JSON.pretty_generate(data)
|
141
|
+
else
|
142
|
+
show_system_component('memory', data)
|
143
|
+
end
|
144
|
+
when 'nics', 'network'
|
145
|
+
data = client.nics
|
146
|
+
if options[:json]
|
147
|
+
puts JSON.pretty_generate(data)
|
148
|
+
else
|
149
|
+
show_system_component('nics', data)
|
150
|
+
end
|
151
|
+
when 'fans', 'cooling'
|
152
|
+
data = client.fans
|
153
|
+
if options[:json]
|
154
|
+
puts JSON.pretty_generate(data)
|
155
|
+
else
|
156
|
+
show_system_component('fans', data)
|
157
|
+
end
|
158
|
+
when 'temps', 'temperatures', 'thermal'
|
159
|
+
data = client.temperatures
|
160
|
+
if options[:json]
|
161
|
+
puts JSON.pretty_generate(data)
|
162
|
+
else
|
163
|
+
show_system_component('temps', data)
|
164
|
+
end
|
165
|
+
when 'psus', 'power-supplies'
|
166
|
+
data = client.psus
|
167
|
+
if options[:json]
|
168
|
+
puts JSON.pretty_generate(data)
|
169
|
+
else
|
170
|
+
show_system_component('psus', data)
|
171
|
+
end
|
172
|
+
else
|
173
|
+
error "Unknown system command: #{subcommand}"
|
174
|
+
puts "Available: all, cpus, memory, nics, fans, temps, psus"
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# Virtual Media Commands
|
180
|
+
desc "media SUBCOMMAND [URL]", "Virtual media management"
|
181
|
+
def media(subcommand = 'status', url = nil)
|
182
|
+
with_client do |client|
|
183
|
+
case subcommand
|
184
|
+
when 'status', 'list'
|
185
|
+
media = client.virtual_media_status
|
186
|
+
if options[:json]
|
187
|
+
puts JSON.pretty_generate(media)
|
188
|
+
else
|
189
|
+
show_media_status(media)
|
190
|
+
end
|
191
|
+
when 'mount', 'insert'
|
192
|
+
if url.nil?
|
193
|
+
error "URL required for mount command"
|
194
|
+
return
|
195
|
+
end
|
196
|
+
result = client.insert_virtual_media(url)
|
197
|
+
output_result({ success: result }, result ? "Media mounted: #{url}" : "Failed to mount media")
|
198
|
+
when 'unmount', 'eject', 'remove'
|
199
|
+
result = client.unmount_all_media
|
200
|
+
output_result({ success: result }, result ? "All media unmounted" : "Failed to unmount media")
|
201
|
+
when 'boot'
|
202
|
+
if url.nil?
|
203
|
+
error "URL required for boot command"
|
204
|
+
return
|
205
|
+
end
|
206
|
+
result = client.mount_iso_and_boot(url)
|
207
|
+
output_result({ success: result }, result ? "ISO mounted and boot set" : "Failed to mount and boot")
|
208
|
+
else
|
209
|
+
error "Unknown media command: #{subcommand}"
|
210
|
+
puts "Available: status, mount URL, unmount, boot URL"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Boot Commands
|
216
|
+
desc "boot SUBCOMMAND [TARGET]", "Boot configuration"
|
217
|
+
option :once, type: :boolean, desc: "Boot override for next boot only"
|
218
|
+
option :continuous, type: :boolean, desc: "Boot override until manually cleared"
|
219
|
+
option :uefi, type: :boolean, desc: "Use UEFI boot mode"
|
220
|
+
option :legacy, type: :boolean, desc: "Use Legacy/BIOS boot mode"
|
221
|
+
def boot(subcommand = 'options', target = nil)
|
222
|
+
with_client do |client|
|
223
|
+
case subcommand
|
224
|
+
when 'options', 'status'
|
225
|
+
opts = client.boot_options
|
226
|
+
if options[:json]
|
227
|
+
puts JSON.pretty_generate(opts)
|
228
|
+
else
|
229
|
+
puts "=== Boot Options ===".green
|
230
|
+
puts "Override: #{opts['boot_source_override_enabled']} -> #{opts['boot_source_override_target']}".cyan
|
231
|
+
puts "Mode: #{opts['boot_source_override_mode']}".cyan if opts['boot_source_override_mode']
|
232
|
+
puts "Allowed: #{opts['allowed_targets']&.join(', ')}".cyan if opts['allowed_targets']
|
233
|
+
end
|
234
|
+
when 'override', 'set'
|
235
|
+
if target.nil?
|
236
|
+
error "Target required (pxe, disk, cd, usb, bios)"
|
237
|
+
return
|
238
|
+
end
|
239
|
+
|
240
|
+
# Determine persistence setting
|
241
|
+
persistence = if options[:once]
|
242
|
+
'Once'
|
243
|
+
elsif options[:continuous]
|
244
|
+
'Continuous'
|
245
|
+
else
|
246
|
+
'Once' # Default to Once
|
247
|
+
end
|
248
|
+
|
249
|
+
# Determine boot mode
|
250
|
+
boot_mode = if options[:uefi]
|
251
|
+
'UEFI'
|
252
|
+
elsif options[:legacy]
|
253
|
+
'Legacy'
|
254
|
+
else
|
255
|
+
nil # Don't change if not specified
|
256
|
+
end
|
257
|
+
|
258
|
+
result = client.set_boot_override(target.capitalize,
|
259
|
+
persistence: persistence,
|
260
|
+
mode: boot_mode)
|
261
|
+
|
262
|
+
mode_str = boot_mode ? " in #{boot_mode} mode" : ""
|
263
|
+
persist_str = " (#{persistence})"
|
264
|
+
output_result({ success: result },
|
265
|
+
result ? "Boot override set to #{target}#{mode_str}#{persist_str}" : "Failed to set boot override")
|
266
|
+
when 'clear', 'reset'
|
267
|
+
result = client.clear_boot_override
|
268
|
+
output_result({ success: result }, result ? "Boot override cleared" : "Failed to clear boot override")
|
269
|
+
when 'pxe'
|
270
|
+
result = set_boot_with_options(client, 'Pxe')
|
271
|
+
output_result({ success: result }, result ? "Boot to PXE set" : "Failed to set PXE boot")
|
272
|
+
when 'disk', 'hdd'
|
273
|
+
result = set_boot_with_options(client, 'Hdd')
|
274
|
+
output_result({ success: result }, result ? "Boot to disk set" : "Failed to set disk boot")
|
275
|
+
when 'cd', 'dvd'
|
276
|
+
result = set_boot_with_options(client, 'Cd')
|
277
|
+
output_result({ success: result }, result ? "Boot to CD set" : "Failed to set CD boot")
|
278
|
+
when 'usb'
|
279
|
+
result = set_boot_with_options(client, 'Usb')
|
280
|
+
output_result({ success: result }, result ? "Boot to USB set" : "Failed to set USB boot")
|
281
|
+
when 'bios', 'setup'
|
282
|
+
result = set_boot_with_options(client, 'BiosSetup')
|
283
|
+
output_result({ success: result }, result ? "Boot to BIOS setup set" : "Failed to set BIOS boot")
|
284
|
+
when 'config'
|
285
|
+
# New subcommand to configure boot settings
|
286
|
+
configure_boot_settings(client)
|
287
|
+
else
|
288
|
+
error "Unknown boot command: #{subcommand}"
|
289
|
+
puts "Available: options, set TARGET, clear, pxe, disk, cd, usb, bios, config"
|
290
|
+
puts "Options: --once, --continuous, --uefi, --legacy"
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
# SEL Commands
|
296
|
+
desc "sel SUBCOMMAND", "System Event Log management"
|
297
|
+
def sel(subcommand = 'show')
|
298
|
+
with_client do |client|
|
299
|
+
case subcommand
|
300
|
+
when 'show', 'list'
|
301
|
+
entries = client.sel_log
|
302
|
+
limit = 10
|
303
|
+
entries = entries.first(limit) if entries.length > limit
|
304
|
+
|
305
|
+
if options[:json]
|
306
|
+
puts JSON.pretty_generate(entries)
|
307
|
+
else
|
308
|
+
puts "=== System Event Log (last #{limit}) ===".green
|
309
|
+
entries.each do |entry|
|
310
|
+
severity_color = case entry['severity']
|
311
|
+
when 'Critical' then :red
|
312
|
+
when 'Warning' then :yellow
|
313
|
+
else :cyan
|
314
|
+
end
|
315
|
+
puts "[#{entry['created']}] #{entry['severity']}".send(severity_color)
|
316
|
+
puts " #{entry['message']}"
|
317
|
+
end
|
318
|
+
end
|
319
|
+
when 'clear'
|
320
|
+
result = client.clear_sel_log
|
321
|
+
output_result({ success: result }, result ? "SEL cleared" : "Failed to clear SEL")
|
322
|
+
else
|
323
|
+
error "Unknown SEL command: #{subcommand}"
|
324
|
+
puts "Available: show, clear"
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
# Storage Commands
|
330
|
+
desc "storage SUBCOMMAND", "Storage information"
|
331
|
+
def storage(subcommand = 'summary')
|
332
|
+
with_client do |client|
|
333
|
+
case subcommand
|
334
|
+
when 'summary', 'all'
|
335
|
+
data = client.storage_summary
|
336
|
+
output_result(data, nil) if options[:json]
|
337
|
+
when 'controllers'
|
338
|
+
data = client.storage_controllers
|
339
|
+
output_result(data, nil) if options[:json]
|
340
|
+
unless options[:json]
|
341
|
+
puts "=== Storage Controllers ===".green
|
342
|
+
data.each do |ctrl|
|
343
|
+
puts "#{ctrl['name']} (#{ctrl['id']})".cyan
|
344
|
+
end
|
345
|
+
end
|
346
|
+
when 'drives', 'disks'
|
347
|
+
data = client.drives
|
348
|
+
if options[:json]
|
349
|
+
puts JSON.pretty_generate(data)
|
350
|
+
else
|
351
|
+
puts "=== Physical Drives ===".green
|
352
|
+
data.each do |drive|
|
353
|
+
puts "#{drive['name']}: #{drive['capacity_gb']} GB - #{drive['status']}".cyan
|
354
|
+
end
|
355
|
+
end
|
356
|
+
when 'volumes', 'raids'
|
357
|
+
data = client.volumes
|
358
|
+
if options[:json]
|
359
|
+
puts JSON.pretty_generate(data)
|
360
|
+
else
|
361
|
+
puts "=== Volumes ===".green
|
362
|
+
data.each do |vol|
|
363
|
+
puts "#{vol['name']}: #{vol['capacity_gb']} GB - #{vol['raid_type']}".cyan
|
364
|
+
end
|
365
|
+
end
|
366
|
+
else
|
367
|
+
error "Unknown storage command: #{subcommand}"
|
368
|
+
puts "Available: summary, controllers, drives, volumes"
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
|
373
|
+
# Config Commands
|
374
|
+
desc "config SUBCOMMAND", "Configuration file management"
|
375
|
+
def config(subcommand = 'generate')
|
376
|
+
case subcommand
|
377
|
+
when 'generate', 'create'
|
378
|
+
config = {
|
379
|
+
'host' => '192.168.1.100',
|
380
|
+
'username' => 'admin',
|
381
|
+
'password' => 'password',
|
382
|
+
'vendor' => 'auto',
|
383
|
+
'port' => 443,
|
384
|
+
'insecure' => true
|
385
|
+
}
|
386
|
+
puts YAML.dump(config)
|
387
|
+
when 'validate'
|
388
|
+
if options[:config]
|
389
|
+
validate_config_file(options[:config])
|
390
|
+
else
|
391
|
+
error "Config file path required (use --config FILE)"
|
392
|
+
end
|
393
|
+
else
|
394
|
+
error "Unknown config command: #{subcommand}"
|
395
|
+
puts "Available: generate, validate"
|
396
|
+
end
|
397
|
+
end
|
398
|
+
|
399
|
+
desc "licenses", "Check BMC licenses (Supermicro only)"
|
400
|
+
def licenses
|
401
|
+
with_client do |client|
|
402
|
+
if client.respond_to?(:check_virtual_media_license)
|
403
|
+
# Supermicro-specific license check
|
404
|
+
license_info = client.check_virtual_media_license
|
405
|
+
|
406
|
+
if options[:json]
|
407
|
+
puts JSON.pretty_generate(license_info)
|
408
|
+
else
|
409
|
+
puts "=== License Status ===".green
|
410
|
+
|
411
|
+
case license_info[:available]
|
412
|
+
when true
|
413
|
+
puts "Virtual Media License: #{'Present'.green}"
|
414
|
+
puts "Licenses: #{license_info[:licenses].join(', ')}"
|
415
|
+
when false
|
416
|
+
puts "Virtual Media License: #{'Missing'.red}"
|
417
|
+
puts license_info[:message].yellow
|
418
|
+
else
|
419
|
+
puts "Virtual Media License: #{'Unknown'.yellow}"
|
420
|
+
puts license_info[:message].yellow
|
421
|
+
end
|
422
|
+
|
423
|
+
# Show all licenses if available
|
424
|
+
if client.respond_to?(:licenses)
|
425
|
+
all_licenses = client.licenses
|
426
|
+
if all_licenses.any?
|
427
|
+
puts "\n=== All Licenses ===".green
|
428
|
+
all_licenses.each do |lic|
|
429
|
+
puts " #{lic[:name]} (ID: #{lic[:id]})".cyan
|
430
|
+
end
|
431
|
+
end
|
432
|
+
end
|
433
|
+
end
|
434
|
+
else
|
435
|
+
error "License checking not supported for this vendor"
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
desc "version", "Show radfish version"
|
441
|
+
def version
|
442
|
+
puts "Radfish #{Radfish::VERSION}"
|
443
|
+
puts "Supported vendors: #{Radfish.supported_vendors.join(', ')}" if Radfish.supported_vendors.any?
|
444
|
+
end
|
445
|
+
|
446
|
+
private
|
447
|
+
|
448
|
+
def set_boot_with_options(client, target)
|
449
|
+
# Determine persistence setting
|
450
|
+
persistence = if options[:once]
|
451
|
+
'Once'
|
452
|
+
elsif options[:continuous]
|
453
|
+
'Continuous'
|
454
|
+
else
|
455
|
+
'Once' # Default to Once
|
456
|
+
end
|
457
|
+
|
458
|
+
# Determine boot mode
|
459
|
+
boot_mode = if options[:uefi]
|
460
|
+
'UEFI'
|
461
|
+
elsif options[:legacy]
|
462
|
+
'Legacy'
|
463
|
+
else
|
464
|
+
nil # Don't change if not specified
|
465
|
+
end
|
466
|
+
|
467
|
+
client.set_boot_override(target, persistence: persistence, mode: boot_mode)
|
468
|
+
end
|
469
|
+
|
470
|
+
def configure_boot_settings(client)
|
471
|
+
# Configure just the boot settings without changing target
|
472
|
+
persistence = if options[:once]
|
473
|
+
'Once'
|
474
|
+
elsif options[:continuous]
|
475
|
+
'Continuous'
|
476
|
+
else
|
477
|
+
nil
|
478
|
+
end
|
479
|
+
|
480
|
+
boot_mode = if options[:uefi]
|
481
|
+
'UEFI'
|
482
|
+
elsif options[:legacy]
|
483
|
+
'Legacy'
|
484
|
+
else
|
485
|
+
nil
|
486
|
+
end
|
487
|
+
|
488
|
+
if persistence.nil? && boot_mode.nil?
|
489
|
+
error "Specify --once/--continuous and/or --uefi/--legacy"
|
490
|
+
return
|
491
|
+
end
|
492
|
+
|
493
|
+
result = client.configure_boot_settings(persistence: persistence, mode: boot_mode)
|
494
|
+
|
495
|
+
settings = []
|
496
|
+
settings << "persistence: #{persistence}" if persistence
|
497
|
+
settings << "mode: #{boot_mode}" if boot_mode
|
498
|
+
|
499
|
+
output_result({ success: result },
|
500
|
+
result ? "Boot settings updated (#{settings.join(', ')})" : "Failed to update boot settings")
|
501
|
+
end
|
502
|
+
|
503
|
+
def with_connection(skip_login: false)
|
504
|
+
opts = load_options
|
505
|
+
|
506
|
+
missing = []
|
507
|
+
missing << 'host' unless opts[:host]
|
508
|
+
missing << 'username' unless opts[:username]
|
509
|
+
missing << 'password' unless opts[:password]
|
510
|
+
|
511
|
+
unless missing.empty?
|
512
|
+
if options[:json]
|
513
|
+
STDERR.puts JSON.generate({
|
514
|
+
error: "Missing required options",
|
515
|
+
missing: missing,
|
516
|
+
message: "Use --host, --username, --password or specify a config file with --config"
|
517
|
+
})
|
518
|
+
else
|
519
|
+
error "Missing required options: #{missing.join(', ')}"
|
520
|
+
error "Use --host, --username, --password or specify a config file with --config"
|
521
|
+
end
|
522
|
+
exit 1
|
523
|
+
end
|
524
|
+
|
525
|
+
yield opts
|
526
|
+
end
|
527
|
+
|
528
|
+
def with_client(&block)
|
529
|
+
with_connection do |opts|
|
530
|
+
client_opts = {
|
531
|
+
host: opts[:host],
|
532
|
+
username: opts[:username],
|
533
|
+
password: opts[:password],
|
534
|
+
port: opts[:port],
|
535
|
+
verify_ssl: !opts[:insecure],
|
536
|
+
direct_mode: true
|
537
|
+
}
|
538
|
+
|
539
|
+
client_opts[:vendor] = opts[:vendor] if opts[:vendor]
|
540
|
+
|
541
|
+
begin
|
542
|
+
client = Radfish::Client.new(**client_opts)
|
543
|
+
client.verbosity = 1 if opts[:verbose]
|
544
|
+
|
545
|
+
client.login
|
546
|
+
yield client
|
547
|
+
rescue => e
|
548
|
+
error "Error: #{e.message}"
|
549
|
+
exit 1
|
550
|
+
ensure
|
551
|
+
client.logout if client rescue nil
|
552
|
+
end
|
553
|
+
end
|
554
|
+
end
|
555
|
+
|
556
|
+
def load_options
|
557
|
+
opts = {}
|
558
|
+
|
559
|
+
# Load from config file if specified
|
560
|
+
if options[:config]
|
561
|
+
config_file = File.expand_path(options[:config])
|
562
|
+
if File.exist?(config_file)
|
563
|
+
config = YAML.load_file(config_file)
|
564
|
+
opts = symbolize_keys(config)
|
565
|
+
else
|
566
|
+
error "Config file not found: #{config_file}"
|
567
|
+
exit 1
|
568
|
+
end
|
569
|
+
end
|
570
|
+
|
571
|
+
# Override with command line options
|
572
|
+
opts[:host] = options[:host] if options[:host]
|
573
|
+
opts[:username] = options[:username] if options[:username]
|
574
|
+
opts[:password] = options[:password] if options[:password]
|
575
|
+
opts[:vendor] = options[:vendor] if options[:vendor]
|
576
|
+
opts[:port] = options[:port] if options[:port]
|
577
|
+
opts[:insecure] = options[:insecure] if options.key?(:insecure)
|
578
|
+
opts[:verbose] = options[:verbose] if options.key?(:verbose)
|
579
|
+
|
580
|
+
# Check environment variables as fallback
|
581
|
+
opts[:host] ||= ENV['RADFISH_HOST']
|
582
|
+
opts[:username] ||= ENV['RADFISH_USERNAME']
|
583
|
+
opts[:password] ||= ENV['RADFISH_PASSWORD']
|
584
|
+
opts[:vendor] ||= ENV['RADFISH_VENDOR']
|
585
|
+
opts[:port] ||= ENV['RADFISH_PORT'].to_i if ENV['RADFISH_PORT']
|
586
|
+
|
587
|
+
opts
|
588
|
+
end
|
589
|
+
|
590
|
+
def symbolize_keys(hash)
|
591
|
+
hash.transform_keys(&:to_sym)
|
592
|
+
end
|
593
|
+
|
594
|
+
def safe_call
|
595
|
+
yield
|
596
|
+
rescue => e
|
597
|
+
options[:verbose] ? e.message : 'N/A'
|
598
|
+
end
|
599
|
+
|
600
|
+
def error(message)
|
601
|
+
if options[:json]
|
602
|
+
STDERR.puts JSON.generate({ error: message })
|
603
|
+
else
|
604
|
+
STDERR.puts message.red
|
605
|
+
end
|
606
|
+
end
|
607
|
+
|
608
|
+
def success(message)
|
609
|
+
puts message.green unless options[:json]
|
610
|
+
end
|
611
|
+
|
612
|
+
def info_msg(message)
|
613
|
+
puts message.cyan unless options[:json]
|
614
|
+
end
|
615
|
+
|
616
|
+
def output_result(data, message, color = :green)
|
617
|
+
if options[:json]
|
618
|
+
puts JSON.pretty_generate(data)
|
619
|
+
elsif message
|
620
|
+
puts message.send(color)
|
621
|
+
end
|
622
|
+
end
|
623
|
+
|
624
|
+
def show_media_status(media_list)
|
625
|
+
puts "\n=== Virtual Media Status ===".green
|
626
|
+
|
627
|
+
if media_list.nil? || media_list.empty?
|
628
|
+
puts "No virtual media devices found".yellow
|
629
|
+
return
|
630
|
+
end
|
631
|
+
|
632
|
+
# Check if any media is inserted
|
633
|
+
has_mounted_media = media_list.any? { |m| m[:inserted] || m[:image] }
|
634
|
+
|
635
|
+
media_list.each do |media|
|
636
|
+
# Display device name with ID if the name is generic
|
637
|
+
device_display = media[:name] || media[:device] || 'Unknown'
|
638
|
+
puts "\nDevice: #{device_display}".cyan
|
639
|
+
|
640
|
+
# Show media types if available
|
641
|
+
puts " Media Types: #{media[:media_types]&.join(', ')}" if media[:media_types]
|
642
|
+
|
643
|
+
# Check for CD/DVD support for boot guidance
|
644
|
+
supports_cd = media[:media_types]&.any? { |t| t.upcase.include?('CD') || t.upcase.include?('DVD') }
|
645
|
+
|
646
|
+
if media[:inserted] || media[:image]
|
647
|
+
# Determine actual status based on image presence
|
648
|
+
if media[:image] && !media[:image].empty? && media[:image] != "http://0.0.0.0/dummy.iso"
|
649
|
+
# Check connection status
|
650
|
+
if media[:connected_via]
|
651
|
+
case media[:connected_via]
|
652
|
+
when "NotConnected"
|
653
|
+
puts " Status: #{'NOT CONNECTED'.red} (ISO won't boot)"
|
654
|
+
puts " Image: #{media[:image]}"
|
655
|
+
puts " Connection: #{'Not Connected - Media will NOT boot!'.red}"
|
656
|
+
when "URI"
|
657
|
+
puts " Status: #{'Connected'.green} (Ready to boot)"
|
658
|
+
puts " Image: #{media[:image]}"
|
659
|
+
puts " Connection: #{'Active via URI'.green}"
|
660
|
+
when "Applet"
|
661
|
+
puts " Status: #{'Connected'.green} (Ready to boot)"
|
662
|
+
puts " Image: #{media[:image]}"
|
663
|
+
puts " Connection: Active via Applet"
|
664
|
+
else
|
665
|
+
puts " Status: Unknown"
|
666
|
+
puts " Image: #{media[:image]}"
|
667
|
+
puts " Connection: #{media[:connected_via]}"
|
668
|
+
end
|
669
|
+
else
|
670
|
+
puts " Image: #{media[:image]}"
|
671
|
+
end
|
672
|
+
|
673
|
+
if supports_cd
|
674
|
+
puts " Boot Command: radfish boot cd".light_blue
|
675
|
+
end
|
676
|
+
else
|
677
|
+
puts " Status: #{'Empty'.yellow}"
|
678
|
+
end
|
679
|
+
else
|
680
|
+
puts " Status: #{'Empty'.yellow}"
|
681
|
+
end
|
682
|
+
end
|
683
|
+
|
684
|
+
if has_mounted_media
|
685
|
+
puts "\nTo boot from virtual media:".green
|
686
|
+
puts " 1. radfish boot cd # Set boot to CD/DVD".cyan
|
687
|
+
puts " 2. radfish power restart # Restart the server".cyan
|
688
|
+
end
|
689
|
+
end
|
690
|
+
|
691
|
+
def show_system_component(type, data)
|
692
|
+
return unless data
|
693
|
+
|
694
|
+
case type
|
695
|
+
when 'cpus'
|
696
|
+
puts "\n=== CPUs ===".green
|
697
|
+
data.each do |cpu|
|
698
|
+
puts "#{cpu['socket']}: #{cpu['manufacturer']} #{cpu['model']}".cyan
|
699
|
+
puts " Cores: #{cpu['cores']}, Threads: #{cpu['threads']}, Speed: #{cpu['speed_mhz']} MHz"
|
700
|
+
end
|
701
|
+
when 'memory'
|
702
|
+
puts "\n=== Memory ===".green
|
703
|
+
total_gb = data.sum { |m| m["capacity_bytes"] || 0 } / (1024.0 ** 3)
|
704
|
+
puts "Total: #{total_gb.round(2)} GB".cyan
|
705
|
+
data.each do |dimm|
|
706
|
+
next if dimm["capacity_bytes"].nil? || dimm["capacity_bytes"] == 0
|
707
|
+
capacity_gb = dimm["capacity_bytes"] / (1024.0 ** 3)
|
708
|
+
puts "#{dimm['name']}: #{capacity_gb.round(2)} GB @ #{dimm['speed_mhz']} MHz"
|
709
|
+
end
|
710
|
+
when 'nics'
|
711
|
+
puts "\n=== Network Interfaces ===".green
|
712
|
+
data.each do |nic|
|
713
|
+
puts "#{nic['name']}: #{nic['mac']}".cyan
|
714
|
+
puts " Speed: #{nic['speed_mbps']} Mbps, Link: #{nic['link_status']}"
|
715
|
+
end
|
716
|
+
when 'fans'
|
717
|
+
puts "\n=== Fans ===".green
|
718
|
+
data.each do |fan|
|
719
|
+
status_color = fan['status'] == 'OK' ? :green : :red
|
720
|
+
puts "#{fan['name']}: #{fan['rpm']} RPM - #{fan['status']}".send(status_color)
|
721
|
+
end
|
722
|
+
when 'temps'
|
723
|
+
puts "\n=== Temperature Sensors ===".green
|
724
|
+
data.each do |temp|
|
725
|
+
next unless temp['reading_celsius']
|
726
|
+
status_color = temp['status'] == 'OK' ? :green : :red
|
727
|
+
puts "#{temp['name']}: #{temp['reading_celsius']}°C".send(status_color)
|
728
|
+
end
|
729
|
+
when 'psus'
|
730
|
+
puts "\n=== Power Supplies ===".green
|
731
|
+
data.each do |psu|
|
732
|
+
status_color = psu['status'] == 'OK' ? :green : :red
|
733
|
+
puts "#{psu['name']}: #{psu['watts']}W - #{psu['status']}".send(status_color)
|
734
|
+
end
|
735
|
+
end
|
736
|
+
end
|
737
|
+
|
738
|
+
def validate_config_file(file)
|
739
|
+
file = File.expand_path(file)
|
740
|
+
if File.exist?(file)
|
741
|
+
begin
|
742
|
+
config = YAML.load_file(file)
|
743
|
+
required = ['host', 'username', 'password']
|
744
|
+
missing = required - config.keys
|
745
|
+
|
746
|
+
if missing.empty?
|
747
|
+
success "Config file is valid"
|
748
|
+
else
|
749
|
+
error "Config file missing required fields: #{missing.join(', ')}"
|
750
|
+
end
|
751
|
+
rescue => e
|
752
|
+
error "Invalid config file: #{e.message}"
|
753
|
+
end
|
754
|
+
else
|
755
|
+
error "Config file not found: #{file}"
|
756
|
+
end
|
757
|
+
end
|
758
|
+
end
|
759
|
+
end
|