supermicro 0.1.7 → 0.1.8

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,14 +6,28 @@ 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"],
19
33
  "status" => controller.dig("Status", "Health") || "N/A",
@@ -28,7 +42,16 @@ module Supermicro
28
42
  }
29
43
  }
30
44
  }
31
- end
45
+
46
+ # Fetch drives for this controller
47
+ if controller["Drives"] && !controller["Drives"].empty?
48
+ controller_data["drives"] = fetch_controller_drives(controller["Id"], controller["Drives"])
49
+ else
50
+ controller_data["drives"] = []
51
+ end
52
+
53
+ controller_data
54
+ end.compact
32
55
 
33
56
  return controllers
34
57
  rescue JSON::ParserError
@@ -38,94 +61,158 @@ module Supermicro
38
61
  raise Error, "Failed to get storage controllers. Status code: #{response.status}"
39
62
  end
40
63
  end
64
+
65
+ private
66
+
67
+ def fetch_controller_drives(controller_id, drive_refs)
68
+ drives = []
69
+
70
+ drive_refs.each do |drive_ref|
71
+ drive_path = drive_ref["@odata.id"]
72
+ drive_response = authenticated_request(:get, drive_path)
73
+
74
+ if drive_response.status == 200
75
+ drive_data = JSON.parse(drive_response.body)
76
+
77
+ drives << {
78
+ "id" => drive_data["Id"],
79
+ "name" => drive_data["Name"],
80
+ "serial" => drive_data["SerialNumber"],
81
+ "manufacturer" => drive_data["Manufacturer"],
82
+ "model" => drive_data["Model"],
83
+ "revision" => drive_data["Revision"],
84
+ "capacity_bytes" => drive_data["CapacityBytes"],
85
+ "speed_gbps" => drive_data["CapableSpeedGbs"] || drive_data["NegotiatedSpeedGbs"],
86
+ "rotation_speed_rpm" => drive_data["RotationSpeedRPM"],
87
+ "media_type" => drive_data["MediaType"],
88
+ "protocol" => drive_data["Protocol"],
89
+ "health" => drive_data.dig("Status", "Health") || "N/A",
90
+ "temperature_celsius" => drive_data.dig("Oem", "Supermicro", "Temperature"),
91
+ "failure_predicted" => drive_data["FailurePredicted"],
92
+ "life_left_percent" => drive_data["PredictedMediaLifeLeftPercent"]
93
+ }
94
+ end
95
+ end
96
+
97
+ drives
98
+ end
99
+
100
+ public
41
101
 
42
- def drives
43
- all_drives = []
102
+ def drives(controller_id)
103
+ # Following natural Redfish pattern - drives are scoped to a controller
104
+ raise ArgumentError, "Controller ID is required" unless controller_id
44
105
 
45
- controllers = storage_controllers
106
+ drives = []
46
107
 
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)
108
+ # Extract just the controller ID if given full path
109
+ controller_name = controller_id.split('/').last
110
+
111
+ response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage/#{controller_name}")
112
+
113
+ if response.status == 200
114
+ begin
115
+ data = JSON.parse(response.body)
116
+
117
+ if data["Drives"]
118
+ data["Drives"].each do |drive_ref|
119
+ drive_path = drive_ref["@odata.id"]
120
+ drive_response = authenticated_request(:get, drive_path)
121
+
122
+ if drive_response.status == 200
123
+ drive_data = JSON.parse(drive_response.body)
60
124
 
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
125
+ drives << {
126
+ "id" => drive_data["Id"],
127
+ "name" => drive_data["Name"],
128
+ "serial" => drive_data["SerialNumber"],
129
+ "manufacturer" => drive_data["Manufacturer"],
130
+ "model" => drive_data["Model"],
131
+ "revision" => drive_data["Revision"],
132
+ "capacity_bytes" => drive_data["CapacityBytes"],
133
+ "capacity_gb" => (drive_data["CapacityBytes"].to_f / (1000**3)).round(2),
134
+ "speed_gbps" => drive_data["CapableSpeedGbs"] || drive_data["NegotiatedSpeedGbs"],
135
+ "rotation_speed_rpm" => drive_data["RotationSpeedRPM"],
136
+ "media_type" => drive_data["MediaType"],
137
+ "protocol" => drive_data["Protocol"],
138
+ "status" => drive_data.dig("Status", "Health") || "N/A",
139
+ "health" => drive_data.dig("Status", "Health") || "N/A",
140
+ "temperature_celsius" => drive_data.dig("Oem", "Supermicro", "Temperature"),
141
+ "failure_predicted" => drive_data["FailurePredicted"],
142
+ "life_left_percent" => drive_data["PredictedMediaLifeLeftPercent"],
143
+ "certified" => drive_data.dig("Oem", "Supermicro", "Certified"),
144
+ "@odata.id" => drive_data["@odata.id"]
145
+ }
81
146
  end
82
147
  end
83
- rescue JSON::ParserError
84
- debug "Failed to parse storage data for controller #{controller_id}", 1, :yellow
85
148
  end
149
+ rescue JSON::ParserError
150
+ debug "Failed to parse storage data for controller #{controller_name}", 1, :yellow
86
151
  end
152
+ else
153
+ raise Error, "Failed to get drives for controller #{controller_name}. Status: #{response.status}"
87
154
  end
88
155
 
89
- return all_drives
156
+ return drives
90
157
  end
91
158
 
92
- def volumes
93
- all_volumes = []
159
+ def volumes(controller_id)
160
+ # Following natural Redfish pattern - volumes are scoped to a controller
161
+ raise ArgumentError, "Controller ID is required" unless controller_id
94
162
 
95
- controllers = storage_controllers
163
+ volumes = []
96
164
 
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
- }
165
+ # Extract just the controller ID if given full path
166
+ controller_name = controller_id.split('/').last
167
+
168
+ response = authenticated_request(:get, "/redfish/v1/Systems/1/Storage/#{controller_name}/Volumes")
169
+
170
+ if response.status == 200
171
+ begin
172
+ data = JSON.parse(response.body)
173
+
174
+ if data["Members"]
175
+ data["Members"].each do |volume_ref|
176
+ # Check if it's a reference or the actual volume data
177
+ if volume_ref["@odata.id"]
178
+ # It's a reference, fetch the volume
179
+ volume_path = volume_ref["@odata.id"]
180
+ volume_response = authenticated_request(:get, volume_path)
181
+
182
+ if volume_response.status == 200
183
+ volume = JSON.parse(volume_response.body)
184
+ else
185
+ next
186
+ end
187
+ else
188
+ # It's the actual volume data
189
+ volume = volume_ref
120
190
  end
191
+
192
+ volumes << {
193
+ "id" => volume["Id"],
194
+ "name" => volume["Name"],
195
+ "capacity_bytes" => volume["CapacityBytes"],
196
+ "capacity_gb" => (volume["CapacityBytes"].to_f / (1000**3)).round(2),
197
+ "volume_type" => volume["VolumeType"],
198
+ "raid_type" => volume["RAIDType"],
199
+ "status" => volume.dig("Status", "Health") || "N/A",
200
+ "health" => volume.dig("Status", "Health") || "N/A",
201
+ "encrypted" => volume["Encrypted"],
202
+ "optimum_io_size_bytes" => volume["OptimumIOSizeBytes"],
203
+ "@odata.id" => volume["@odata.id"]
204
+ }
121
205
  end
122
- rescue JSON::ParserError
123
- debug "Failed to parse volumes data for controller #{controller_id}", 1, :yellow
124
206
  end
207
+ rescue JSON::ParserError
208
+ debug "Failed to parse volumes data for controller #{controller_name}", 1, :yellow
125
209
  end
210
+ else
211
+ # Some controllers may not have volumes endpoint
212
+ debug "No volumes endpoint for controller #{controller_name} (Status: #{response.status})", 2, :yellow
126
213
  end
127
214
 
128
- return all_volumes
215
+ return volumes
129
216
  end
130
217
 
131
218
  def storage_summary
@@ -144,7 +231,13 @@ module Supermicro
144
231
  end
145
232
  end
146
233
 
147
- all_drives = drives
234
+ # Get all drives from all controllers
235
+ all_drives = []
236
+ controllers.each do |controller|
237
+ controller_drives = drives(controller["@odata.id"] || "/redfish/v1/Systems/1/Storage/#{controller['id']}")
238
+ all_drives.concat(controller_drives) if controller_drives
239
+ end
240
+
148
241
  puts "\nPhysical Drives:".cyan
149
242
  all_drives.each do |drive|
150
243
  puts " #{drive['name']} (#{drive['id']})".yellow
@@ -158,7 +251,13 @@ module Supermicro
158
251
  end
159
252
  end
160
253
 
161
- all_volumes = volumes
254
+ # Get all volumes from all controllers
255
+ all_volumes = []
256
+ controllers.each do |controller|
257
+ controller_volumes = volumes(controller["@odata.id"] || "/redfish/v1/Systems/1/Storage/#{controller['id']}")
258
+ all_volumes.concat(controller_volumes) if controller_volumes
259
+ end
260
+
162
261
  if all_volumes.any?
163
262
  puts "\nVolumes:".cyan
164
263
  all_volumes.each do |volume|