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.
@@ -6,28 +6,49 @@ require 'colorize'
6
6
  module Supermicro
7
7
  module Jobs
8
8
  def jobs
9
- response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks?$expand=*($levels=1)")
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
- tasks = data["Members"]&.map do |task|
16
- {
17
- "id" => task["Id"],
18
- "name" => task["Name"],
19
- "state" => task["TaskState"],
20
- "status" => task["TaskStatus"],
21
- "percent_complete" => task["PercentComplete"] || task.dig("Oem", "Supermicro", "PercentComplete"),
22
- "start_time" => task["StartTime"],
23
- "end_time" => task["EndTime"],
24
- "messages" => task["Messages"]
25
- }
26
- end || []
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: #{response.body}"
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 clear_completed_jobs
120
- all_jobs = jobs
121
- completed = all_jobs.select { |j| j["state"] == "Completed" }
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
- if completed.empty?
124
- puts "No completed jobs to clear.".yellow
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
- puts "Clearing #{completed.length} completed jobs...".yellow
129
-
130
- success = true
131
- completed.each do |job|
132
- begin
133
- response = authenticated_request(
134
- :delete,
135
- "/redfish/v1/TaskService/Tasks/#{job["id"]}"
136
- )
137
-
138
- if response.status.between?(200, 299)
139
- puts " Cleared: #{job["name"]} (#{job["id"]})".green
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
- success
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 = jobs
182
+ all_jobs = jobs_detail
155
183
 
156
184
  puts "\n=== Jobs Summary ===".green
157
185
 
@@ -11,21 +11,25 @@ module Supermicro
11
11
  if response.status == 200
12
12
  data = JSON.parse(response.body)
13
13
  {
14
- "ipv4_address" => data.dig("IPv4Addresses", 0, "Address"),
15
- "subnet_mask" => data.dig("IPv4Addresses", 0, "SubnetMask"),
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
- "mac_address" => data["MACAddress"],
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(ip_address: nil, subnet_mask: nil, gateway: nil,
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 ip_address && subnet_mask
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" => ip_address,
49
- "SubnetMask" => subnet_mask,
52
+ "Address" => ipv4,
53
+ "SubnetMask" => mask,
50
54
  "Gateway" => gateway
51
55
  }]
52
- puts " IP: #{ip_address}/#{subnet_mask}".cyan
56
+ puts " IP: #{ipv4}/#{mask}".cyan
53
57
  puts " Gateway: #{gateway}".cyan if gateway
54
58
  end
55
59
 
@@ -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?$expand=*($levels=1)")
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 |controller|
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
- end
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
- all_drives = []
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
- controllers = storage_controllers
112
+ drives = []
46
113
 
47
- controllers.each do |controller|
48
- controller_id = controller["id"]
49
-
50
- response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage/#{controller_id}")
51
-
52
- if response.status == 200
53
- begin
54
- data = JSON.parse(response.body)
55
-
56
- if data["Drives"]
57
- data["Drives"].each do |drive_ref|
58
- drive_path = drive_ref["@odata.id"]
59
- drive_response = authenticated_request(:get, drive_path)
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
- if drive_response.status == 200
62
- drive_data = JSON.parse(drive_response.body)
63
-
64
- all_drives << {
65
- "id" => drive_data["Id"],
66
- "name" => drive_data["Name"],
67
- "manufacturer" => drive_data["Manufacturer"],
68
- "model" => drive_data["Model"],
69
- "serial" => drive_data["SerialNumber"],
70
- "capacity_bytes" => drive_data["CapacityBytes"],
71
- "capacity_gb" => (drive_data["CapacityBytes"].to_f / 1_000_000_000).round(2),
72
- "protocol" => drive_data["Protocol"],
73
- "media_type" => drive_data["MediaType"],
74
- "status" => drive_data.dig("Status", "Health") || "N/A",
75
- "controller" => controller_id,
76
- "firmware_version" => drive_data["Revision"],
77
- "rotation_speed_rpm" => drive_data["RotationSpeedRPM"],
78
- "predicted_media_life_left_percent" => drive_data["PredictedMediaLifeLeftPercent"]
79
- }
80
- end
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 all_drives
162
+ return drives
90
163
  end
91
164
 
92
- def volumes
93
- all_volumes = []
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
- controllers = storage_controllers
169
+ volumes = []
96
170
 
97
- controllers.each do |controller|
98
- controller_id = controller["id"]
99
-
100
- response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage/#{controller_id}/Volumes?$expand=*($levels=1)")
101
-
102
- if response.status == 200
103
- begin
104
- data = JSON.parse(response.body)
105
-
106
- if data["Members"]
107
- data["Members"].each do |volume|
108
- all_volumes << {
109
- "id" => volume["Id"],
110
- "name" => volume["Name"],
111
- "capacity_bytes" => volume["CapacityBytes"],
112
- "capacity_gb" => (volume["CapacityBytes"].to_f / 1_000_000_000).round(2),
113
- "volume_type" => volume["VolumeType"],
114
- "raid_type" => volume["RAIDType"],
115
- "status" => volume.dig("Status", "Health") || "N/A",
116
- "controller" => controller_id,
117
- "encrypted" => volume["Encrypted"],
118
- "optimum_io_size_bytes" => volume["OptimumIOSizeBytes"]
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 all_volumes
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
- all_drives = drives
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
- all_volumes = volumes
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|