radfish-ami 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 348afbf5f47b34017b22b55684ae17457b46ed74d4f160c85e2c648b4ac30b55
4
+ data.tar.gz: 5538ed34280b652637c588280019bff2fd08cbc420854b63fe96d9d190e572c3
5
+ SHA512:
6
+ metadata.gz: 1107faba3f94f2f40b5e4f21e2ecd2889715fa0806dca5b2503c6926206c6d80d0657aee05fccb2f6bdc270790e7a1a3550ed63f839178051f352abaee210a4d
7
+ data.tar.gz: 786c5c3e9fc08e8b52b9b7c866bdacb7e9ced148bdfb68a75eee0b5bd754731e5383e7343712fa9d0e79ba85fd8872885440be691395eb3b978db47a3648fc0b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jonathan Siegel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # Radfish-AMI
2
+
3
+ AMI BMC adapter for the [Radfish](https://github.com/buildio/radfish) Redfish API client. Provides support for ASRockRack servers and other systems using AMI MegaRAC BMC firmware.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'radfish-ami'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ gem install radfish-ami
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ The adapter is automatically registered when you require the gem:
28
+
29
+ ```ruby
30
+ require 'radfish-ami'
31
+
32
+ # Connect to an ASRockRack server
33
+ client = Radfish.connect(
34
+ host: 'bmc.example.com',
35
+ username: 'admin',
36
+ password: 'your-password',
37
+ vendor: 'ami' # or 'asrockrack'
38
+ )
39
+
40
+ # Get system information
41
+ puts client.system_info
42
+
43
+ # Power operations
44
+ puts client.power_status # => "On"
45
+ client.power_restart(force: true)
46
+
47
+ # Virtual media
48
+ client.insert_virtual_media('http://example.com/boot.iso')
49
+ client.set_boot_override('Cd', persistent: false)
50
+ client.power_restart
51
+
52
+ # Thermal data
53
+ client.fans.each { |fan| puts "#{fan['Name']}: #{fan['Reading']} RPM" }
54
+ client.temperatures.each { |t| puts "#{t['Name']}: #{t['ReadingCelsius']}C" }
55
+
56
+ # Storage
57
+ client.storage_controllers.each do |controller|
58
+ puts "Controller: #{controller.name}"
59
+ client.drives(controller).each { |d| puts " Drive: #{d['Name']}" }
60
+ end
61
+
62
+ # Disconnect
63
+ client.logout
64
+ ```
65
+
66
+ ## Supported Features
67
+
68
+ - **Power Management**: Power on, off, restart, cycle, status
69
+ - **System Information**: Make, model, serial, BIOS version, CPU, memory, NICs
70
+ - **Thermal Monitoring**: Fan speeds, temperatures
71
+ - **Power Monitoring**: PSU status, power consumption
72
+ - **Storage**: Controllers, drives, volumes
73
+ - **Virtual Media**: Insert/eject ISO images
74
+ - **Boot Configuration**: Set boot order, one-time boot overrides
75
+ - **Network Configuration**: BMC network settings
76
+ - **Account Management**: Create, delete, update user accounts
77
+ - **SEL Log**: Read and clear system event logs
78
+ - **Task/Job Management**: Monitor long-running operations
79
+
80
+ ## Tested Hardware
81
+
82
+ - ASRockRack GENOAD8UD-2T/X550 (AMD EPYC)
83
+ - Other ASRockRack servers with AMI MegaRAC BMC
84
+
85
+ ## License
86
+
87
+ MIT License - see LICENSE file.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radfish
4
+ module Ami
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience require for the gem
4
+ require_relative "../radfish-ami"
@@ -0,0 +1,790 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radfish
4
+ class AmiAdapter < Core::BaseClient
5
+ include Core::Power
6
+ include Core::System
7
+ include Core::Storage
8
+ include Core::VirtualMedia
9
+ include Core::Boot
10
+ include Core::Jobs
11
+ include Core::Utility
12
+ include Core::Network
13
+
14
+ # AMI BMC uses "Self" as the default system/manager/chassis ID
15
+ SYSTEM_ID = "Self"
16
+ MANAGER_ID = "Self"
17
+ CHASSIS_ID = "Self"
18
+
19
+ def vendor
20
+ "ami"
21
+ end
22
+
23
+ # Session management
24
+ def login
25
+ @session = Core::Session.new(self)
26
+ @session.create
27
+ end
28
+
29
+ def logout
30
+ return true unless @session
31
+ result = @session.delete
32
+ @session = nil
33
+ result
34
+ end
35
+
36
+ def authenticated_request(method, path, **options)
37
+ ensure_session!
38
+
39
+ headers = options[:headers] || {}
40
+ headers["X-Auth-Token"] = @session.x_auth_token
41
+ headers["Accept"] ||= "application/json"
42
+ headers["Content-Type"] ||= "application/json" if [:post, :put, :patch].include?(method)
43
+ headers["Host"] = host_header if host_header
44
+
45
+ options[:headers] = headers
46
+
47
+ case method
48
+ when :get
49
+ http_get(path, **options)
50
+ when :post
51
+ http_post(path, **options)
52
+ when :put
53
+ http_put(path, **options)
54
+ when :patch
55
+ http_patch(path, **options)
56
+ when :delete
57
+ http_delete(path, **options)
58
+ else
59
+ raise ArgumentError, "Unknown HTTP method: #{method}"
60
+ end
61
+ end
62
+
63
+ # Power management
64
+ def power_status
65
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}")
66
+ if response.status == 200
67
+ data = JSON.parse(response.body)
68
+ data["PowerState"]
69
+ else
70
+ raise Error, "Failed to get power status: #{response.status}"
71
+ end
72
+ end
73
+
74
+ def power_on
75
+ perform_reset_action("On")
76
+ end
77
+
78
+ def power_off(force: true)
79
+ if force
80
+ perform_reset_action("ForceOff")
81
+ else
82
+ perform_reset_action("GracefulShutdown")
83
+ end
84
+ end
85
+
86
+ def power_restart(force: true)
87
+ if force
88
+ perform_reset_action("ForceRestart")
89
+ else
90
+ # Graceful restart: shutdown then power on
91
+ power_off(force: false)
92
+ sleep 5
93
+ power_on
94
+ end
95
+ end
96
+
97
+ def power_cycle
98
+ perform_reset_action("PowerCycle")
99
+ end
100
+
101
+ def reset_type_allowed
102
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/ResetActionInfo")
103
+ if response.status == 200
104
+ data = JSON.parse(response.body)
105
+ params = data.dig("Parameters") || []
106
+ reset_param = params.find { |p| p["Name"] == "ResetType" }
107
+ reset_param&.dig("AllowableValues") || []
108
+ else
109
+ # Fallback to common AMI reset types
110
+ %w[On ForceOff ForceRestart GracefulShutdown PowerCycle]
111
+ end
112
+ end
113
+
114
+ # System information
115
+ def system_info
116
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}")
117
+ raise Error, "Failed to get system info: #{response.status}" unless response.status == 200
118
+ JSON.parse(response.body)
119
+ end
120
+
121
+ def service_tag
122
+ system_info["SerialNumber"]
123
+ end
124
+
125
+ def make
126
+ info = system_info
127
+ info["Manufacturer"] || "ASRockRack"
128
+ end
129
+
130
+ def model
131
+ system_info["Model"]
132
+ end
133
+
134
+ def serial
135
+ system_info["SerialNumber"]
136
+ end
137
+
138
+ def cpus
139
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/Processors")
140
+ return [] unless response.status == 200
141
+
142
+ collection = JSON.parse(response.body)
143
+ members = collection["Members"] || []
144
+
145
+ members.map do |member|
146
+ cpu_response = authenticated_request(:get, member["@odata.id"])
147
+ next nil unless cpu_response.status == 200
148
+ JSON.parse(cpu_response.body)
149
+ end.compact
150
+ end
151
+
152
+ def memory
153
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/Memory")
154
+ return [] unless response.status == 200
155
+
156
+ collection = JSON.parse(response.body)
157
+ members = collection["Members"] || []
158
+
159
+ members.map do |member|
160
+ mem_response = authenticated_request(:get, member["@odata.id"])
161
+ next nil unless mem_response.status == 200
162
+ JSON.parse(mem_response.body)
163
+ end.compact
164
+ end
165
+
166
+ def nics
167
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/EthernetInterfaces")
168
+ return [] unless response.status == 200
169
+
170
+ collection = JSON.parse(response.body)
171
+ members = collection["Members"] || []
172
+
173
+ members.map do |member|
174
+ nic_response = authenticated_request(:get, member["@odata.id"])
175
+ next nil unless nic_response.status == 200
176
+ JSON.parse(nic_response.body)
177
+ end.compact
178
+ end
179
+
180
+ def fans
181
+ thermal = get_thermal_data
182
+ thermal["Fans"] || []
183
+ end
184
+
185
+ def temperatures
186
+ thermal = get_thermal_data
187
+ thermal["Temperatures"] || []
188
+ end
189
+
190
+ def psus
191
+ power_data = get_power_data
192
+ power_data["PowerSupplies"] || []
193
+ end
194
+
195
+ def power_consumption
196
+ power_data = get_power_data
197
+ power_data["PowerControl"]&.first || {}
198
+ end
199
+
200
+ def power_consumption_watts
201
+ consumption = power_consumption
202
+ consumption.dig("PowerMetrics", "AverageConsumedWatts") ||
203
+ consumption.dig("PowerMetrics", "CurConsumedWatts") ||
204
+ 0
205
+ end
206
+
207
+ # Storage
208
+ def storage_controllers
209
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/Storage")
210
+ return [] unless response.status == 200
211
+
212
+ collection = JSON.parse(response.body)
213
+ members = collection["Members"] || []
214
+
215
+ members.map do |member|
216
+ controller_response = authenticated_request(:get, member["@odata.id"])
217
+ next nil unless controller_response.status == 200
218
+ data = JSON.parse(controller_response.body)
219
+ Radfish::Controller.new(
220
+ client: self,
221
+ id: data["Id"],
222
+ name: data["Name"],
223
+ model: data["Model"],
224
+ status: data.dig("Status", "Health"),
225
+ adapter_data: data
226
+ )
227
+ end.compact
228
+ end
229
+
230
+ def drives(controller)
231
+ controller_id = controller.is_a?(Radfish::Controller) ? controller.id : controller
232
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/Storage/#{controller_id}")
233
+ return [] unless response.status == 200
234
+
235
+ data = JSON.parse(response.body)
236
+ drive_refs = data["Drives"] || []
237
+
238
+ drive_refs.map do |ref|
239
+ drive_response = authenticated_request(:get, ref["@odata.id"])
240
+ next nil unless drive_response.status == 200
241
+ JSON.parse(drive_response.body)
242
+ end.compact
243
+ end
244
+
245
+ def volumes(controller)
246
+ controller_obj = controller.is_a?(Radfish::Controller) ? controller : nil
247
+ controller_id = controller.is_a?(Radfish::Controller) ? controller.id : controller
248
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/Storage/#{controller_id}/Volumes")
249
+ return [] unless response.status == 200
250
+
251
+ collection = JSON.parse(response.body)
252
+ members = collection["Members"] || []
253
+
254
+ members.map do |member|
255
+ volume_response = authenticated_request(:get, member["@odata.id"])
256
+ next nil unless volume_response.status == 200
257
+ data = JSON.parse(volume_response.body)
258
+ Radfish::Volume.new(
259
+ client: self,
260
+ controller: controller_obj,
261
+ id: data["Id"],
262
+ name: data["Name"],
263
+ capacity_bytes: data["CapacityBytes"],
264
+ raid_type: data["RAIDType"],
265
+ health: data.dig("Status", "Health"),
266
+ adapter_data: data
267
+ )
268
+ end.compact
269
+ end
270
+
271
+ def volume_drives(volume)
272
+ volume_id = volume.is_a?(Radfish::Volume) ? volume.id : volume
273
+ # Get volume details to find linked drives
274
+ volume_data = volume.is_a?(Radfish::Volume) ? volume.adapter_data : nil
275
+
276
+ unless volume_data
277
+ # Need to fetch volume data
278
+ storage_controllers.each do |controller|
279
+ vols = volumes(controller)
280
+ vol = vols.find { |v| v.id == volume_id }
281
+ if vol
282
+ volume_data = vol.adapter_data
283
+ break
284
+ end
285
+ end
286
+ end
287
+
288
+ return [] unless volume_data
289
+
290
+ drive_refs = volume_data.dig("Links", "Drives") || []
291
+ drive_refs.map do |ref|
292
+ drive_response = authenticated_request(:get, ref["@odata.id"])
293
+ next nil unless drive_response.status == 200
294
+ JSON.parse(drive_response.body)
295
+ end.compact
296
+ end
297
+
298
+ def storage_summary
299
+ controllers = storage_controllers
300
+ {
301
+ controller_count: controllers.size,
302
+ controllers: controllers.map do |c|
303
+ {
304
+ id: c.id,
305
+ name: c.name,
306
+ drive_count: drives(c).size,
307
+ volume_count: volumes(c).size
308
+ }
309
+ end
310
+ }
311
+ end
312
+
313
+ # Virtual Media
314
+ def virtual_media
315
+ response = authenticated_request(:get, "/redfish/v1/Managers/#{MANAGER_ID}/VirtualMedia")
316
+ return [] unless response.status == 200
317
+
318
+ collection = JSON.parse(response.body)
319
+ members = collection["Members"] || []
320
+
321
+ members.map do |member|
322
+ vm_response = authenticated_request(:get, member["@odata.id"])
323
+ next nil unless vm_response.status == 200
324
+ JSON.parse(vm_response.body)
325
+ end.compact
326
+ end
327
+
328
+ def virtual_media_status
329
+ virtual_media.map do |vm|
330
+ {
331
+ id: vm["Id"],
332
+ name: vm["Name"],
333
+ media_types: vm["MediaTypes"],
334
+ inserted: vm["Inserted"],
335
+ image: vm["Image"],
336
+ connected: vm["ConnectedVia"]
337
+ }
338
+ end
339
+ end
340
+
341
+ def insert_virtual_media(iso_url, device: nil)
342
+ devices = virtual_media
343
+ target_device = if device
344
+ devices.find { |d| d["Id"] == device || d["Name"]&.include?(device.to_s) }
345
+ else
346
+ # Find first CD/DVD device
347
+ devices.find { |d| d["MediaTypes"]&.include?("CD") || d["MediaTypes"]&.include?("DVD") }
348
+ end
349
+
350
+ raise VirtualMediaNotFoundError, "No suitable virtual media device found" unless target_device
351
+
352
+ device_path = target_device["@odata.id"]
353
+ actions = target_device.dig("Actions", "#VirtualMedia.InsertMedia")
354
+
355
+ if actions && actions["target"]
356
+ # Use the InsertMedia action
357
+ payload = { "Image" => iso_url, "Inserted" => true, "WriteProtected" => true }
358
+ response = authenticated_request(:post, actions["target"], body: payload.to_json)
359
+ else
360
+ # Fallback to PATCH method
361
+ payload = { "Image" => iso_url, "Inserted" => true, "WriteProtected" => true }
362
+ response = authenticated_request(:patch, device_path, body: payload.to_json)
363
+ end
364
+
365
+ if response.status.between?(200, 204)
366
+ debug "Virtual media inserted successfully", 1, :green
367
+ true
368
+ else
369
+ error_msg = begin
370
+ JSON.parse(response.body).dig("error", "message")
371
+ rescue
372
+ response.body
373
+ end
374
+ raise VirtualMediaError, "Failed to insert virtual media: #{error_msg}"
375
+ end
376
+ end
377
+
378
+ def eject_virtual_media(device: nil)
379
+ devices = virtual_media
380
+ target_device = if device
381
+ devices.find { |d| d["Id"] == device || d["Name"]&.include?(device.to_s) }
382
+ else
383
+ # Find first mounted device
384
+ devices.find { |d| d["Inserted"] == true }
385
+ end
386
+
387
+ return true unless target_device && target_device["Inserted"]
388
+
389
+ device_path = target_device["@odata.id"]
390
+ actions = target_device.dig("Actions", "#VirtualMedia.EjectMedia")
391
+
392
+ if actions && actions["target"]
393
+ response = authenticated_request(:post, actions["target"], body: "{}".to_json)
394
+ else
395
+ payload = { "Image" => nil, "Inserted" => false }
396
+ response = authenticated_request(:patch, device_path, body: payload.to_json)
397
+ end
398
+
399
+ response.status.between?(200, 204)
400
+ end
401
+
402
+ def unmount_all_media
403
+ virtual_media.each do |device|
404
+ next unless device["Inserted"]
405
+ eject_virtual_media(device: device["Id"])
406
+ end
407
+ true
408
+ end
409
+
410
+ def mount_iso_and_boot(iso_url, device: nil)
411
+ insert_virtual_media(iso_url, device: device)
412
+ set_boot_override("Cd", persistent: false)
413
+ power_restart(force: true)
414
+ end
415
+
416
+ # Boot configuration
417
+ def boot_config
418
+ info = system_info
419
+ info["Boot"] || {}
420
+ end
421
+
422
+ def set_boot_override(target, persistent: false)
423
+ enabled = persistent ? "Continuous" : "Once"
424
+ payload = {
425
+ "Boot" => {
426
+ "BootSourceOverrideTarget" => target,
427
+ "BootSourceOverrideEnabled" => enabled
428
+ }
429
+ }
430
+
431
+ response = authenticated_request(:patch, "/redfish/v1/Systems/#{SYSTEM_ID}", body: payload.to_json)
432
+
433
+ if response.status.between?(200, 204)
434
+ debug "Boot override set to #{target} (#{enabled})", 1, :green
435
+ true
436
+ else
437
+ error_msg = begin
438
+ JSON.parse(response.body).dig("error", "message")
439
+ rescue
440
+ response.body
441
+ end
442
+ raise Error, "Failed to set boot override: #{error_msg}"
443
+ end
444
+ end
445
+
446
+ def clear_boot_override
447
+ payload = {
448
+ "Boot" => {
449
+ "BootSourceOverrideTarget" => "None",
450
+ "BootSourceOverrideEnabled" => "Disabled"
451
+ }
452
+ }
453
+
454
+ response = authenticated_request(:patch, "/redfish/v1/Systems/#{SYSTEM_ID}", body: payload.to_json)
455
+ response.status.between?(200, 204)
456
+ end
457
+
458
+ def set_boot_order(devices)
459
+ payload = {
460
+ "Boot" => {
461
+ "BootOrder" => devices
462
+ }
463
+ }
464
+
465
+ response = authenticated_request(:patch, "/redfish/v1/Systems/#{SYSTEM_ID}", body: payload.to_json)
466
+ response.status.between?(200, 204)
467
+ end
468
+
469
+ def get_boot_devices
470
+ boot_config["BootOrder"] || []
471
+ end
472
+
473
+ def boot_to_pxe(persistent: false)
474
+ set_boot_override("Pxe", persistent: persistent)
475
+ end
476
+
477
+ def boot_to_disk(persistent: false)
478
+ set_boot_override("Hdd", persistent: persistent)
479
+ end
480
+
481
+ def boot_to_cd(persistent: false)
482
+ set_boot_override("Cd", persistent: persistent)
483
+ end
484
+
485
+ def boot_to_usb(persistent: false)
486
+ set_boot_override("Usb", persistent: persistent)
487
+ end
488
+
489
+ def boot_to_bios_setup(persistent: false)
490
+ set_boot_override("BiosSetup", persistent: persistent)
491
+ end
492
+
493
+ # Jobs/Tasks
494
+ def jobs
495
+ response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks")
496
+ return [] unless response.status == 200
497
+
498
+ collection = JSON.parse(response.body)
499
+ members = collection["Members"] || []
500
+
501
+ members.map do |member|
502
+ task_response = authenticated_request(:get, member["@odata.id"])
503
+ next nil unless task_response.status == 200
504
+ JSON.parse(task_response.body)
505
+ end.compact
506
+ end
507
+
508
+ def job_status(job_id)
509
+ response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks/#{job_id}")
510
+ if response.status == 200
511
+ JSON.parse(response.body)
512
+ else
513
+ raise TaskError, "Failed to get task status: #{response.status}"
514
+ end
515
+ end
516
+
517
+ def wait_for_job(job_id, timeout: 600)
518
+ start_time = Time.now
519
+ loop do
520
+ status = job_status(job_id)
521
+ state = status["TaskState"]
522
+
523
+ case state
524
+ when "Completed"
525
+ return status
526
+ when "Exception", "Killed", "Cancelled"
527
+ raise TaskFailedError, "Task #{job_id} failed: #{status['Messages']}"
528
+ end
529
+
530
+ if Time.now - start_time > timeout
531
+ raise TaskTimeoutError, "Task #{job_id} timed out after #{timeout} seconds"
532
+ end
533
+
534
+ sleep 5
535
+ end
536
+ end
537
+
538
+ def cancel_job(job_id)
539
+ response = authenticated_request(:delete, "/redfish/v1/TaskService/Tasks/#{job_id}")
540
+ response.status.between?(200, 204)
541
+ end
542
+
543
+ def clear_completed_jobs
544
+ jobs.each do |job|
545
+ next unless %w[Completed Exception Killed Cancelled].include?(job["TaskState"])
546
+ cancel_job(job["Id"])
547
+ end
548
+ true
549
+ end
550
+
551
+ def jobs_summary
552
+ all_jobs = jobs
553
+ {
554
+ total: all_jobs.size,
555
+ running: all_jobs.count { |j| j["TaskState"] == "Running" },
556
+ completed: all_jobs.count { |j| j["TaskState"] == "Completed" },
557
+ failed: all_jobs.count { |j| %w[Exception Killed Cancelled].include?(j["TaskState"]) }
558
+ }
559
+ end
560
+
561
+ # Utility
562
+ def sel_log
563
+ response = authenticated_request(:get, "/redfish/v1/Systems/#{SYSTEM_ID}/LogServices/Log1/Entries")
564
+ return [] unless response.status == 200
565
+
566
+ collection = JSON.parse(response.body)
567
+ collection["Members"] || []
568
+ end
569
+
570
+ def clear_sel_log
571
+ response = authenticated_request(:post, "/redfish/v1/Systems/#{SYSTEM_ID}/LogServices/Log1/Actions/LogService.ClearLog", body: "{}".to_json)
572
+ response.status.between?(200, 204)
573
+ end
574
+
575
+ def sel_summary(limit: 10)
576
+ entries = sel_log.first(limit)
577
+ entries.map do |entry|
578
+ {
579
+ id: entry["Id"],
580
+ created: entry["Created"],
581
+ severity: entry["Severity"],
582
+ message: entry["Message"]
583
+ }
584
+ end
585
+ end
586
+
587
+ def accounts
588
+ response = authenticated_request(:get, "/redfish/v1/AccountService/Accounts")
589
+ return [] unless response.status == 200
590
+
591
+ collection = JSON.parse(response.body)
592
+ members = collection["Members"] || []
593
+
594
+ members.map do |member|
595
+ account_response = authenticated_request(:get, member["@odata.id"])
596
+ next nil unless account_response.status == 200
597
+ JSON.parse(account_response.body)
598
+ end.compact
599
+ end
600
+
601
+ def create_account(username:, password:, role: "Administrator")
602
+ payload = {
603
+ "UserName" => username,
604
+ "Password" => password,
605
+ "RoleId" => role
606
+ }
607
+
608
+ response = authenticated_request(:post, "/redfish/v1/AccountService/Accounts", body: payload.to_json)
609
+
610
+ if response.status == 201
611
+ JSON.parse(response.body)
612
+ else
613
+ error_msg = begin
614
+ JSON.parse(response.body).dig("error", "message")
615
+ rescue
616
+ response.body
617
+ end
618
+ raise Error, "Failed to create account: #{error_msg}"
619
+ end
620
+ end
621
+
622
+ def delete_account(username)
623
+ account = accounts.find { |a| a["UserName"] == username }
624
+ raise NotFoundError, "Account not found: #{username}" unless account
625
+
626
+ response = authenticated_request(:delete, account["@odata.id"])
627
+ response.status.between?(200, 204)
628
+ end
629
+
630
+ def update_account_password(username:, new_password:)
631
+ account = accounts.find { |a| a["UserName"] == username }
632
+ raise NotFoundError, "Account not found: #{username}" unless account
633
+
634
+ payload = { "Password" => new_password }
635
+ response = authenticated_request(:patch, account["@odata.id"], body: payload.to_json)
636
+ response.status.between?(200, 204)
637
+ end
638
+
639
+ def sessions
640
+ response = authenticated_request(:get, "/redfish/v1/SessionService/Sessions")
641
+ return [] unless response.status == 200
642
+
643
+ collection = JSON.parse(response.body)
644
+ members = collection["Members"] || []
645
+
646
+ members.map do |member|
647
+ session_response = authenticated_request(:get, member["@odata.id"])
648
+ next nil unless session_response.status == 200
649
+ JSON.parse(session_response.body)
650
+ end.compact
651
+ end
652
+
653
+ def service_info
654
+ service_root
655
+ end
656
+
657
+ def get_firmware_version
658
+ response = authenticated_request(:get, "/redfish/v1/Managers/#{MANAGER_ID}")
659
+ if response.status == 200
660
+ data = JSON.parse(response.body)
661
+ data["FirmwareVersion"]
662
+ else
663
+ nil
664
+ end
665
+ end
666
+
667
+ # Network configuration
668
+ def get_bmc_network
669
+ response = authenticated_request(:get, "/redfish/v1/Managers/#{MANAGER_ID}/EthernetInterfaces")
670
+ return {} unless response.status == 200
671
+
672
+ collection = JSON.parse(response.body)
673
+ members = collection["Members"] || []
674
+ return {} if members.empty?
675
+
676
+ # Get the first interface (typically the main BMC interface)
677
+ interface_response = authenticated_request(:get, members.first["@odata.id"])
678
+ return {} unless interface_response.status == 200
679
+
680
+ data = JSON.parse(interface_response.body)
681
+
682
+ ipv4 = data.dig("IPv4Addresses", 0) || {}
683
+ dhcp_enabled = data.dig("DHCPv4", "DHCPEnabled")
684
+ {
685
+ "id" => data["Id"],
686
+ "hostname" => data["HostName"],
687
+ "fqdn" => data["FQDN"],
688
+ "mac_address" => data["MACAddress"],
689
+ "ipv4_address" => ipv4["Address"],
690
+ "subnet_mask" => ipv4["SubnetMask"],
691
+ "gateway" => ipv4["Gateway"],
692
+ "mode" => dhcp_enabled ? "DHCP" : "Static",
693
+ "address_origin" => ipv4["AddressOrigin"],
694
+ "dhcp_enabled" => dhcp_enabled,
695
+ "dns_servers" => data["NameServers"]
696
+ }
697
+ end
698
+
699
+ def set_bmc_network(ip_address: nil, subnet_mask: nil, gateway: nil,
700
+ dns_primary: nil, dns_secondary: nil, hostname: nil,
701
+ dhcp: false)
702
+ response = authenticated_request(:get, "/redfish/v1/Managers/#{MANAGER_ID}/EthernetInterfaces")
703
+ return false unless response.status == 200
704
+
705
+ collection = JSON.parse(response.body)
706
+ members = collection["Members"] || []
707
+ return false if members.empty?
708
+
709
+ interface_path = members.first["@odata.id"]
710
+
711
+ if dhcp
712
+ payload = {
713
+ "DHCPv4" => { "DHCPEnabled" => true }
714
+ }
715
+ else
716
+ payload = {}
717
+
718
+ if ip_address || subnet_mask || gateway
719
+ payload["IPv4Addresses"] = [{
720
+ "Address" => ip_address,
721
+ "SubnetMask" => subnet_mask,
722
+ "Gateway" => gateway
723
+ }.compact]
724
+ end
725
+
726
+ if dns_primary || dns_secondary
727
+ payload["NameServers"] = [dns_primary, dns_secondary].compact
728
+ end
729
+
730
+ payload["HostName"] = hostname if hostname
731
+
732
+ payload["DHCPv4"] = { "DHCPEnabled" => false }
733
+ end
734
+
735
+ response = authenticated_request(:patch, interface_path, body: payload.to_json)
736
+ response.status.between?(200, 204)
737
+ end
738
+
739
+ private
740
+
741
+ def ensure_session!
742
+ unless @session&.x_auth_token
743
+ raise AuthenticationError, "Not logged in. Call #login first."
744
+ end
745
+ end
746
+
747
+ def perform_reset_action(reset_type)
748
+ payload = { "ResetType" => reset_type }
749
+ response = authenticated_request(
750
+ :post,
751
+ "/redfish/v1/Systems/#{SYSTEM_ID}/Actions/ComputerSystem.Reset",
752
+ body: payload.to_json
753
+ )
754
+
755
+ if response.status.between?(200, 204)
756
+ debug "Reset action #{reset_type} performed successfully", 1, :green
757
+ true
758
+ else
759
+ error_msg = begin
760
+ JSON.parse(response.body).dig("error", "message")
761
+ rescue
762
+ response.body
763
+ end
764
+ raise Error, "Failed to perform reset action #{reset_type}: #{error_msg}"
765
+ end
766
+ end
767
+
768
+ def get_thermal_data
769
+ response = authenticated_request(:get, "/redfish/v1/Chassis/#{CHASSIS_ID}/Thermal")
770
+ if response.status == 200
771
+ JSON.parse(response.body)
772
+ else
773
+ {}
774
+ end
775
+ end
776
+
777
+ def get_power_data
778
+ response = authenticated_request(:get, "/redfish/v1/Chassis/#{CHASSIS_ID}/Power")
779
+ if response.status == 200
780
+ JSON.parse(response.body)
781
+ else
782
+ {}
783
+ end
784
+ end
785
+ end
786
+
787
+ # Register the AMI adapter
788
+ register_adapter("ami", AmiAdapter)
789
+ register_adapter("asrockrack", AmiAdapter)
790
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "radfish"
4
+ require_relative "radfish/ami/version"
5
+ require_relative "radfish/ami_adapter"
6
+
7
+ module Radfish
8
+ module Ami
9
+ end
10
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/radfish/ami/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "radfish-ami"
7
+ spec.version = Radfish::Ami::VERSION
8
+ spec.authors = ["Jonathan Siegel"]
9
+ spec.email = ["248302+usiegj00@users.noreply.github.com"]
10
+
11
+ spec.summary = "AMI/ASRockRack adapter for Radfish"
12
+ spec.description = "AMI BMC adapter for Radfish Redfish API client. Provides support for ASRockRack servers and other systems using AMI MegaRAC BMC firmware."
13
+ spec.homepage = "https://github.com/buildio/radfish-ami"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.1.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ Dir["{lib}/**/*", "LICENSE", "README.md", "*.gemspec"].reject { |f| File.directory?(f) }
23
+ end
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_dependency "radfish", "~> 0.2"
27
+
28
+ spec.add_development_dependency "bundler", "~> 2.0"
29
+ spec.add_development_dependency "rake", "~> 13.0"
30
+ spec.add_development_dependency "rspec", "~> 3.0"
31
+ spec.add_development_dependency "webmock", "~> 3.0"
32
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: radfish-ami
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Siegel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-12-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: radfish
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ description: AMI BMC adapter for Radfish Redfish API client. Provides support for
84
+ ASRockRack servers and other systems using AMI MegaRAC BMC firmware.
85
+ email:
86
+ - 248302+usiegj00@users.noreply.github.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - LICENSE
92
+ - README.md
93
+ - lib/radfish-ami.rb
94
+ - lib/radfish/ami.rb
95
+ - lib/radfish/ami/version.rb
96
+ - lib/radfish/ami_adapter.rb
97
+ - radfish-ami.gemspec
98
+ homepage: https://github.com/buildio/radfish-ami
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ homepage_uri: https://github.com/buildio/radfish-ami
103
+ source_code_uri: https://github.com/buildio/radfish-ami
104
+ changelog_uri: https://github.com/buildio/radfish-ami/blob/main/CHANGELOG.md
105
+ post_install_message:
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: 3.1.0
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.5.22
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: AMI/ASRockRack adapter for Radfish
124
+ test_files: []