supermicro 0.1.7 → 0.1.9
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 +4 -4
- data/lib/supermicro/bios.rb +277 -0
- data/lib/supermicro/boot.rb +188 -55
- data/lib/supermicro/client.rb +1 -0
- data/lib/supermicro/jobs.rb +67 -39
- data/lib/supermicro/network.rb +13 -9
- data/lib/supermicro/storage.rb +179 -74
- data/lib/supermicro/system.rb +104 -14
- data/lib/supermicro/system_config.rb +2 -75
- data/lib/supermicro/tasks.rb +39 -1
- data/lib/supermicro/utility.rb +59 -23
- data/lib/supermicro/version.rb +1 -1
- data/lib/supermicro/virtual_media.rb +53 -10
- data/lib/supermicro.rb +1 -0
- metadata +3 -2
data/lib/supermicro/jobs.rb
CHANGED
@@ -6,28 +6,49 @@ require 'colorize'
|
|
6
6
|
module Supermicro
|
7
7
|
module Jobs
|
8
8
|
def jobs
|
9
|
-
|
9
|
+
tasks = jobs_detail
|
10
|
+
|
11
|
+
# Return summary format consistent with iDRAC
|
12
|
+
{
|
13
|
+
completed_count: tasks.count { |t| t["state"] == "Completed" },
|
14
|
+
incomplete_count: tasks.count { |t| t["state"] != "Completed" },
|
15
|
+
total_count: tasks.count
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
def jobs_detail
|
20
|
+
response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks")
|
10
21
|
|
11
22
|
if response.status == 200
|
12
23
|
begin
|
13
24
|
data = JSON.parse(response.body)
|
25
|
+
members = data["Members"] || []
|
14
26
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
+
# Supermicro doesn't support expand, so fetch each task individually
|
28
|
+
tasks = members.map do |member|
|
29
|
+
task_id = member["@odata.id"].split('/').last
|
30
|
+
task_response = authenticated_request(:get, member["@odata.id"])
|
31
|
+
|
32
|
+
if task_response.status == 200
|
33
|
+
task = JSON.parse(task_response.body)
|
34
|
+
{
|
35
|
+
"id" => task["Id"],
|
36
|
+
"name" => task["Name"],
|
37
|
+
"state" => task["TaskState"],
|
38
|
+
"status" => task["TaskStatus"],
|
39
|
+
"percent_complete" => task["PercentComplete"],
|
40
|
+
"start_time" => task["StartTime"],
|
41
|
+
"end_time" => task["EndTime"],
|
42
|
+
"messages" => task["Messages"]
|
43
|
+
}
|
44
|
+
else
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
end.compact
|
27
48
|
|
28
49
|
return tasks
|
29
50
|
rescue JSON::ParserError
|
30
|
-
raise Error, "Failed to parse tasks response
|
51
|
+
raise Error, "Failed to parse tasks response"
|
31
52
|
end
|
32
53
|
else
|
33
54
|
[]
|
@@ -116,42 +137,49 @@ module Supermicro
|
|
116
137
|
end
|
117
138
|
end
|
118
139
|
|
119
|
-
def
|
120
|
-
|
121
|
-
|
140
|
+
def clear_jobs!
|
141
|
+
# Note: Supermicro doesn't actually delete tasks - DELETE just marks them as "Killed"
|
142
|
+
# The BMC maintains a rolling buffer of tasks (typically ~28-30) with oldest being overwritten
|
143
|
+
# This method will "kill" any running tasks but won't remove them from the list
|
122
144
|
|
123
|
-
|
124
|
-
|
145
|
+
response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks")
|
146
|
+
return true unless response.status == 200
|
147
|
+
|
148
|
+
data = JSON.parse(response.body)
|
149
|
+
members = data["Members"] || []
|
150
|
+
|
151
|
+
if members.empty?
|
152
|
+
puts "No jobs to clear.".yellow
|
125
153
|
return true
|
126
154
|
end
|
127
155
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
)
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
else
|
141
|
-
puts " Failed to clear: #{job["name"]} (#{job["id"]})".red
|
142
|
-
success = false
|
156
|
+
# Only try to kill tasks that are actually running
|
157
|
+
running_count = 0
|
158
|
+
members.each do |member|
|
159
|
+
task_id = member["@odata.id"].split('/').last
|
160
|
+
task_response = authenticated_request(:get, member["@odata.id"])
|
161
|
+
|
162
|
+
if task_response.status == 200
|
163
|
+
task = JSON.parse(task_response.body)
|
164
|
+
if ["Running", "Starting", "New", "Pending"].include?(task["TaskState"])
|
165
|
+
running_count += 1
|
166
|
+
puts "Killing task #{task_id}: #{task['Name']} (#{task['TaskState']})".yellow
|
167
|
+
authenticated_request(:delete, member["@odata.id"])
|
143
168
|
end
|
144
|
-
rescue => e
|
145
|
-
puts " Error clearing #{job["id"]}: #{e.message}".red
|
146
|
-
success = false
|
147
169
|
end
|
148
170
|
end
|
149
171
|
|
150
|
-
|
172
|
+
if running_count > 0
|
173
|
+
puts "Killed #{running_count} running tasks.".green
|
174
|
+
else
|
175
|
+
puts "No running tasks to kill (#{members.length} completed/killed tasks remain in history).".yellow
|
176
|
+
end
|
177
|
+
|
178
|
+
true
|
151
179
|
end
|
152
180
|
|
153
181
|
def jobs_summary
|
154
|
-
all_jobs =
|
182
|
+
all_jobs = jobs_detail
|
155
183
|
|
156
184
|
puts "\n=== Jobs Summary ===".green
|
157
185
|
|
data/lib/supermicro/network.rb
CHANGED
@@ -11,21 +11,25 @@ module Supermicro
|
|
11
11
|
if response.status == 200
|
12
12
|
data = JSON.parse(response.body)
|
13
13
|
{
|
14
|
-
"
|
15
|
-
"
|
14
|
+
"ipv4" => data.dig("IPv4Addresses", 0, "Address"),
|
15
|
+
"mask" => data.dig("IPv4Addresses", 0, "SubnetMask"),
|
16
16
|
"gateway" => data.dig("IPv4Addresses", 0, "Gateway"),
|
17
17
|
"mode" => data.dig("IPv4Addresses", 0, "AddressOrigin"), # DHCP or Static
|
18
|
-
"
|
18
|
+
"mac" => data["MACAddress"],
|
19
19
|
"hostname" => data["HostName"],
|
20
20
|
"fqdn" => data["FQDN"],
|
21
|
-
"dns_servers" => data["NameServers"] || []
|
21
|
+
"dns_servers" => data["NameServers"] || [],
|
22
|
+
"name" => data["Id"] || "BMC",
|
23
|
+
"speed_mbps" => data["SpeedMbps"] || 1000,
|
24
|
+
"status" => data.dig("Status", "Health") || "OK",
|
25
|
+
"kind" => "ethernet"
|
22
26
|
}
|
23
27
|
else
|
24
28
|
raise Error, "Failed to get BMC network config. Status: #{response.status}"
|
25
29
|
end
|
26
30
|
end
|
27
31
|
|
28
|
-
def set_bmc_network(
|
32
|
+
def set_bmc_network(ipv4: nil, mask: nil, gateway: nil,
|
29
33
|
dns_primary: nil, dns_secondary: nil, hostname: nil,
|
30
34
|
dhcp: false, wait: true)
|
31
35
|
|
@@ -41,15 +45,15 @@ module Supermicro
|
|
41
45
|
body = {}
|
42
46
|
|
43
47
|
# Configure static IP if provided
|
44
|
-
if
|
48
|
+
if ipv4 && mask
|
45
49
|
# Must explicitly disable DHCP when setting static IP
|
46
50
|
body["DHCPv4"] = { "DHCPEnabled" => false }
|
47
51
|
body["IPv4StaticAddresses"] = [{
|
48
|
-
"Address" =>
|
49
|
-
"SubnetMask" =>
|
52
|
+
"Address" => ipv4,
|
53
|
+
"SubnetMask" => mask,
|
50
54
|
"Gateway" => gateway
|
51
55
|
}]
|
52
|
-
puts " IP: #{
|
56
|
+
puts " IP: #{ipv4}/#{mask}".cyan
|
53
57
|
puts " Gateway: #{gateway}".cyan if gateway
|
54
58
|
end
|
55
59
|
|
data/lib/supermicro/storage.rb
CHANGED
@@ -6,17 +6,37 @@ require 'colorize'
|
|
6
6
|
module Supermicro
|
7
7
|
module Storage
|
8
8
|
def storage_controllers
|
9
|
-
response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage
|
9
|
+
response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage")
|
10
10
|
|
11
11
|
if response.status == 200
|
12
12
|
begin
|
13
13
|
data = JSON.parse(response.body)
|
14
14
|
|
15
|
-
controllers = data["Members"].map do |
|
16
|
-
|
15
|
+
controllers = data["Members"].map do |member|
|
16
|
+
# If member is just a reference, fetch the full data
|
17
|
+
if member["@odata.id"] && !member["Id"]
|
18
|
+
controller_path = member["@odata.id"]
|
19
|
+
controller_response = authenticated_request(:get, controller_path)
|
20
|
+
|
21
|
+
if controller_response.status == 200
|
22
|
+
controller = JSON.parse(controller_response.body)
|
23
|
+
else
|
24
|
+
next nil
|
25
|
+
end
|
26
|
+
else
|
27
|
+
controller = member
|
28
|
+
end
|
29
|
+
|
30
|
+
controller_data = {
|
17
31
|
"id" => controller["Id"],
|
18
32
|
"name" => controller["Name"],
|
33
|
+
# Extract model from first StorageController entry
|
34
|
+
"model" => controller.dig("StorageControllers", 0, "Model") || controller["Name"],
|
35
|
+
"firmware_version" => controller.dig("StorageControllers", 0, "FirmwareVersion"),
|
19
36
|
"status" => controller.dig("Status", "Health") || "N/A",
|
37
|
+
"drives_count" => controller["Drives"]&.size || 0,
|
38
|
+
"@odata.id" => controller["@odata.id"],
|
39
|
+
# Include full storage controller info for reference
|
20
40
|
"storage_controllers" => controller["StorageControllers"]&.map { |sc|
|
21
41
|
{
|
22
42
|
"name" => sc["Name"],
|
@@ -28,7 +48,16 @@ module Supermicro
|
|
28
48
|
}
|
29
49
|
}
|
30
50
|
}
|
31
|
-
|
51
|
+
|
52
|
+
# Fetch drives for this controller
|
53
|
+
if controller["Drives"] && !controller["Drives"].empty?
|
54
|
+
controller_data["drives"] = fetch_controller_drives(controller["Id"], controller["Drives"])
|
55
|
+
else
|
56
|
+
controller_data["drives"] = []
|
57
|
+
end
|
58
|
+
|
59
|
+
controller_data
|
60
|
+
end.compact
|
32
61
|
|
33
62
|
return controllers
|
34
63
|
rescue JSON::ParserError
|
@@ -38,94 +67,158 @@ module Supermicro
|
|
38
67
|
raise Error, "Failed to get storage controllers. Status code: #{response.status}"
|
39
68
|
end
|
40
69
|
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def fetch_controller_drives(controller_id, drive_refs)
|
74
|
+
drives = []
|
75
|
+
|
76
|
+
drive_refs.each do |drive_ref|
|
77
|
+
drive_path = drive_ref["@odata.id"]
|
78
|
+
drive_response = authenticated_request(:get, drive_path)
|
79
|
+
|
80
|
+
if drive_response.status == 200
|
81
|
+
drive_data = JSON.parse(drive_response.body)
|
82
|
+
|
83
|
+
drives << {
|
84
|
+
"id" => drive_data["Id"],
|
85
|
+
"name" => drive_data["Name"],
|
86
|
+
"serial" => drive_data["SerialNumber"],
|
87
|
+
"manufacturer" => drive_data["Manufacturer"],
|
88
|
+
"model" => drive_data["Model"],
|
89
|
+
"revision" => drive_data["Revision"],
|
90
|
+
"capacity_bytes" => drive_data["CapacityBytes"],
|
91
|
+
"speed_gbps" => drive_data["CapableSpeedGbs"] || drive_data["NegotiatedSpeedGbs"],
|
92
|
+
"rotation_speed_rpm" => drive_data["RotationSpeedRPM"],
|
93
|
+
"media_type" => drive_data["MediaType"],
|
94
|
+
"protocol" => drive_data["Protocol"],
|
95
|
+
"health" => drive_data.dig("Status", "Health") || "N/A",
|
96
|
+
"temperature_celsius" => drive_data.dig("Oem", "Supermicro", "Temperature"),
|
97
|
+
"failure_predicted" => drive_data["FailurePredicted"],
|
98
|
+
"life_left_percent" => drive_data["PredictedMediaLifeLeftPercent"]
|
99
|
+
}
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
drives
|
104
|
+
end
|
105
|
+
|
106
|
+
public
|
41
107
|
|
42
|
-
def drives
|
43
|
-
|
108
|
+
def drives(controller_id)
|
109
|
+
# Following natural Redfish pattern - drives are scoped to a controller
|
110
|
+
raise ArgumentError, "Controller ID is required" unless controller_id
|
44
111
|
|
45
|
-
|
112
|
+
drives = []
|
46
113
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
114
|
+
# Extract just the controller ID if given full path
|
115
|
+
controller_name = controller_id.split('/').last
|
116
|
+
|
117
|
+
response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage/#{controller_name}")
|
118
|
+
|
119
|
+
if response.status == 200
|
120
|
+
begin
|
121
|
+
data = JSON.parse(response.body)
|
122
|
+
|
123
|
+
if data["Drives"]
|
124
|
+
data["Drives"].each do |drive_ref|
|
125
|
+
drive_path = drive_ref["@odata.id"]
|
126
|
+
drive_response = authenticated_request(:get, drive_path)
|
127
|
+
|
128
|
+
if drive_response.status == 200
|
129
|
+
drive_data = JSON.parse(drive_response.body)
|
60
130
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
131
|
+
drives << {
|
132
|
+
"id" => drive_data["Id"],
|
133
|
+
"name" => drive_data["Name"],
|
134
|
+
"serial" => drive_data["SerialNumber"],
|
135
|
+
"manufacturer" => drive_data["Manufacturer"],
|
136
|
+
"model" => drive_data["Model"],
|
137
|
+
"revision" => drive_data["Revision"],
|
138
|
+
"capacity_bytes" => drive_data["CapacityBytes"],
|
139
|
+
"capacity_gb" => (drive_data["CapacityBytes"].to_f / (1000**3)).round(2),
|
140
|
+
"speed_gbps" => drive_data["CapableSpeedGbs"] || drive_data["NegotiatedSpeedGbs"],
|
141
|
+
"rotation_speed_rpm" => drive_data["RotationSpeedRPM"],
|
142
|
+
"media_type" => drive_data["MediaType"],
|
143
|
+
"protocol" => drive_data["Protocol"],
|
144
|
+
"status" => drive_data.dig("Status", "Health") || "N/A",
|
145
|
+
"health" => drive_data.dig("Status", "Health") || "N/A",
|
146
|
+
"temperature_celsius" => drive_data.dig("Oem", "Supermicro", "Temperature"),
|
147
|
+
"failure_predicted" => drive_data["FailurePredicted"],
|
148
|
+
"life_left_percent" => drive_data["PredictedMediaLifeLeftPercent"],
|
149
|
+
"certified" => drive_data.dig("Oem", "Supermicro", "Certified"),
|
150
|
+
"@odata.id" => drive_data["@odata.id"]
|
151
|
+
}
|
81
152
|
end
|
82
153
|
end
|
83
|
-
rescue JSON::ParserError
|
84
|
-
debug "Failed to parse storage data for controller #{controller_id}", 1, :yellow
|
85
154
|
end
|
155
|
+
rescue JSON::ParserError
|
156
|
+
debug "Failed to parse storage data for controller #{controller_name}", 1, :yellow
|
86
157
|
end
|
158
|
+
else
|
159
|
+
raise Error, "Failed to get drives for controller #{controller_name}. Status: #{response.status}"
|
87
160
|
end
|
88
161
|
|
89
|
-
return
|
162
|
+
return drives
|
90
163
|
end
|
91
164
|
|
92
|
-
def volumes
|
93
|
-
|
165
|
+
def volumes(controller_id)
|
166
|
+
# Following natural Redfish pattern - volumes are scoped to a controller
|
167
|
+
raise ArgumentError, "Controller ID is required" unless controller_id
|
94
168
|
|
95
|
-
|
169
|
+
volumes = []
|
96
170
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
171
|
+
# Extract just the controller ID if given full path
|
172
|
+
controller_name = controller_id.split('/').last
|
173
|
+
|
174
|
+
response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage/#{controller_name}/Volumes")
|
175
|
+
|
176
|
+
if response.status == 200
|
177
|
+
begin
|
178
|
+
data = JSON.parse(response.body)
|
179
|
+
|
180
|
+
if data["Members"]
|
181
|
+
data["Members"].each do |volume_ref|
|
182
|
+
# Check if it's a reference or the actual volume data
|
183
|
+
if volume_ref["@odata.id"]
|
184
|
+
# It's a reference, fetch the volume
|
185
|
+
volume_path = volume_ref["@odata.id"]
|
186
|
+
volume_response = authenticated_request(:get, volume_path)
|
187
|
+
|
188
|
+
if volume_response.status == 200
|
189
|
+
volume = JSON.parse(volume_response.body)
|
190
|
+
else
|
191
|
+
next
|
192
|
+
end
|
193
|
+
else
|
194
|
+
# It's the actual volume data
|
195
|
+
volume = volume_ref
|
120
196
|
end
|
197
|
+
|
198
|
+
volumes << {
|
199
|
+
"id" => volume["Id"],
|
200
|
+
"name" => volume["Name"],
|
201
|
+
"capacity_bytes" => volume["CapacityBytes"],
|
202
|
+
"capacity_gb" => (volume["CapacityBytes"].to_f / (1000**3)).round(2),
|
203
|
+
"volume_type" => volume["VolumeType"],
|
204
|
+
"raid_type" => volume["RAIDType"],
|
205
|
+
"status" => volume.dig("Status", "Health") || "N/A",
|
206
|
+
"health" => volume.dig("Status", "Health") || "N/A",
|
207
|
+
"encrypted" => volume["Encrypted"],
|
208
|
+
"optimum_io_size_bytes" => volume["OptimumIOSizeBytes"],
|
209
|
+
"@odata.id" => volume["@odata.id"]
|
210
|
+
}
|
121
211
|
end
|
122
|
-
rescue JSON::ParserError
|
123
|
-
debug "Failed to parse volumes data for controller #{controller_id}", 1, :yellow
|
124
212
|
end
|
213
|
+
rescue JSON::ParserError
|
214
|
+
debug "Failed to parse volumes data for controller #{controller_name}", 1, :yellow
|
125
215
|
end
|
216
|
+
else
|
217
|
+
# Some controllers may not have volumes endpoint
|
218
|
+
debug "No volumes endpoint for controller #{controller_name} (Status: #{response.status})", 2, :yellow
|
126
219
|
end
|
127
220
|
|
128
|
-
return
|
221
|
+
return volumes
|
129
222
|
end
|
130
223
|
|
131
224
|
def storage_summary
|
@@ -144,7 +237,13 @@ module Supermicro
|
|
144
237
|
end
|
145
238
|
end
|
146
239
|
|
147
|
-
|
240
|
+
# Get all drives from all controllers
|
241
|
+
all_drives = []
|
242
|
+
controllers.each do |controller|
|
243
|
+
controller_drives = drives(controller["@odata.id"] || "/redfish/v1/Systems/1/Storage/#{controller['id']}")
|
244
|
+
all_drives.concat(controller_drives) if controller_drives
|
245
|
+
end
|
246
|
+
|
148
247
|
puts "\nPhysical Drives:".cyan
|
149
248
|
all_drives.each do |drive|
|
150
249
|
puts " #{drive['name']} (#{drive['id']})".yellow
|
@@ -158,7 +257,13 @@ module Supermicro
|
|
158
257
|
end
|
159
258
|
end
|
160
259
|
|
161
|
-
|
260
|
+
# Get all volumes from all controllers
|
261
|
+
all_volumes = []
|
262
|
+
controllers.each do |controller|
|
263
|
+
controller_volumes = volumes(controller["@odata.id"] || "/redfish/v1/Systems/1/Storage/#{controller['id']}")
|
264
|
+
all_volumes.concat(controller_volumes) if controller_volumes
|
265
|
+
end
|
266
|
+
|
162
267
|
if all_volumes.any?
|
163
268
|
puts "\nVolumes:".cyan
|
164
269
|
all_volumes.each do |volume|
|