supermicro 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.
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/multipart'
5
+ require 'nokogiri'
6
+ require 'base64'
7
+ require 'uri'
8
+ require 'httparty'
9
+ require 'json'
10
+ require 'colorize'
11
+
12
+ module Supermicro
13
+ class Client
14
+ attr_reader :host, :username, :password, :port, :use_ssl, :verify_ssl, :session, :host_header
15
+ attr_accessor :direct_mode, :verbosity, :retry_count, :retry_delay
16
+
17
+ include Power
18
+ include Debuggable
19
+ include Jobs
20
+ include Storage
21
+ include System
22
+ include VirtualMedia
23
+ include Boot
24
+ include SystemConfig
25
+ include Utility
26
+ include License
27
+ include Tasks
28
+
29
+ def initialize(host:, username:, password:, port: 443, use_ssl: true, verify_ssl: false, direct_mode: false, retry_count: 3, retry_delay: 1, host_header: nil)
30
+ @host = host
31
+ @username = username
32
+ @password = password
33
+ @port = port
34
+ @use_ssl = use_ssl
35
+ @verify_ssl = verify_ssl
36
+ @direct_mode = direct_mode
37
+ @host_header = host_header
38
+ @verbosity = 0
39
+ @retry_count = retry_count
40
+ @retry_delay = retry_delay
41
+
42
+ @session = Session.new(self)
43
+
44
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@session))
45
+ end
46
+
47
+ def self.finalizer(session)
48
+ proc do
49
+ begin
50
+ session.delete if session.x_auth_token
51
+ rescue
52
+ end
53
+ end
54
+ end
55
+
56
+ def self.connect(host:, username:, password:, **options)
57
+ client = new(host: host, username: username, password: password, **options)
58
+ return client unless block_given?
59
+
60
+ begin
61
+ client.login
62
+ yield client
63
+ ensure
64
+ client.logout
65
+ end
66
+ end
67
+
68
+ def connection
69
+ @connection ||= Faraday.new(url: base_url, ssl: { verify: verify_ssl }) do |faraday|
70
+ faraday.request :multipart
71
+ faraday.request :url_encoded
72
+ faraday.adapter Faraday.default_adapter
73
+ if @verbosity > 0
74
+ faraday.response :logger, Logger.new(STDOUT), bodies: @verbosity >= 2 do |logger|
75
+ logger.filter(/(Authorization: Basic )([^,\n]+)/, '\1[FILTERED]')
76
+ logger.filter(/(Password"=>"?)([^,"]+)/, '\1[FILTERED]')
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def login
83
+ if @direct_mode
84
+ debug "Using direct mode (Basic Auth) for all requests", 1, :light_yellow
85
+ return true
86
+ end
87
+
88
+ if session.create
89
+ debug "Successfully logged in to Supermicro BMC using Redfish session", 1, :green
90
+ return true
91
+ else
92
+ debug "Failed to create Redfish session, falling back to direct mode", 1, :light_yellow
93
+ @direct_mode = true
94
+ return true
95
+ end
96
+ end
97
+
98
+ def logout
99
+ session.delete if session.x_auth_token
100
+ debug "Logged out from Supermicro BMC", 1, :green
101
+ return true
102
+ end
103
+
104
+ def authenticated_request(method, path, body: nil, headers: {}, timeout: nil, open_timeout: nil, **options)
105
+ request_options = {
106
+ body: body,
107
+ headers: headers,
108
+ timeout: timeout,
109
+ open_timeout: open_timeout
110
+ }.merge(options).compact
111
+
112
+ with_retries do
113
+ _perform_authenticated_request(method, path, request_options)
114
+ end
115
+ end
116
+
117
+ def get(path:, headers: {})
118
+ with_retries do
119
+ _perform_get(path: path, headers: headers)
120
+ end
121
+ end
122
+
123
+ def base_url
124
+ protocol = use_ssl ? 'https' : 'http'
125
+ "#{protocol}://#{host}:#{port}"
126
+ end
127
+
128
+ def redfish_version
129
+ response = authenticated_request(:get, "/redfish/v1")
130
+ if response.status == 200
131
+ data = JSON.parse(response.body)
132
+ data["RedfishVersion"]
133
+ else
134
+ raise Error, "Failed to get Redfish version: #{response.status} - #{response.body}"
135
+ end
136
+ end
137
+
138
+ def get_firmware_version
139
+ response = authenticated_request(:get, "/redfish/v1/Managers/1?$select=FirmwareVersion")
140
+
141
+ if response.status == 200
142
+ begin
143
+ data = JSON.parse(response.body)
144
+ return data["FirmwareVersion"]
145
+ rescue JSON::ParserError
146
+ raise Error, "Failed to parse firmware version response: #{response.body}"
147
+ end
148
+ else
149
+ response = authenticated_request(:get, "/redfish/v1/Managers/1")
150
+
151
+ if response.status == 200
152
+ begin
153
+ data = JSON.parse(response.body)
154
+ return data["FirmwareVersion"]
155
+ rescue JSON::ParserError
156
+ raise Error, "Failed to parse firmware version response: #{response.body}"
157
+ end
158
+ else
159
+ raise Error, "Failed to get firmware version. Status code: #{response.status}"
160
+ end
161
+ end
162
+ end
163
+
164
+ def with_retries(max_retries = nil, initial_delay = nil, error_classes = nil)
165
+ max_retries ||= @retry_count
166
+ initial_delay ||= @retry_delay
167
+ error_classes ||= [StandardError]
168
+
169
+ retries = 0
170
+ begin
171
+ yield
172
+ rescue *error_classes => e
173
+ retries += 1
174
+ if retries <= max_retries
175
+ delay = initial_delay * (retries ** 1.5).to_i
176
+ debug "RETRY: #{e.message} - Attempt #{retries}/#{max_retries}, waiting #{delay}s", 1, :yellow
177
+ sleep delay
178
+ retry
179
+ else
180
+ debug "MAX RETRIES REACHED: #{e.message} after #{max_retries} attempts", 1, :red
181
+ raise e
182
+ end
183
+ end
184
+ end
185
+
186
+ def wait_for_task(task_id)
187
+ task = nil
188
+
189
+ begin
190
+ loop do
191
+ task_response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks/#{task_id}")
192
+
193
+ case task_response.status
194
+ when 200..299
195
+ task = JSON.parse(task_response.body)
196
+
197
+ if task["TaskState"] != "Running"
198
+ break
199
+ end
200
+
201
+ percent_complete = nil
202
+ if task["Oem"] && task["Oem"]["Supermicro"] && task["Oem"]["Supermicro"]["PercentComplete"]
203
+ percent_complete = task["Oem"]["Supermicro"]["PercentComplete"]
204
+ debug "Task progress: #{percent_complete}% complete", 1
205
+ end
206
+
207
+ debug "Waiting for task to complete...: #{task["TaskState"]} #{task["TaskStatus"]}", 1
208
+ sleep 5
209
+ else
210
+ return {
211
+ status: :failed,
212
+ error: "Failed to check task status: #{task_response.status} - #{task_response.body}"
213
+ }
214
+ end
215
+ end
216
+
217
+ if task["TaskState"] == "Completed" && task["TaskStatus"] == "OK"
218
+ return { status: :success }
219
+ else
220
+ debug task.inspect, 1, :yellow
221
+
222
+ messages = []
223
+ if task["Messages"] && task["Messages"].is_a?(Array)
224
+ messages = task["Messages"].map { |m| m["Message"] }.compact
225
+ end
226
+
227
+ return {
228
+ status: :failed,
229
+ task_state: task["TaskState"],
230
+ task_status: task["TaskStatus"],
231
+ messages: messages,
232
+ error: messages.first || "Task failed with state: #{task["TaskState"]}"
233
+ }
234
+ end
235
+ rescue => e
236
+ return { status: :error, error: "Exception monitoring task: #{e.message}" }
237
+ end
238
+ end
239
+
240
+ def handle_response(response)
241
+ if response.headers["location"]
242
+ return handle_location(response.headers["location"])
243
+ end
244
+
245
+ if response.status.between?(200, 299)
246
+ return response.body
247
+ else
248
+ raise Error, "Failed to #{response.status} - #{response.body}"
249
+ end
250
+ end
251
+
252
+ def handle_location(location)
253
+ return nil if location.nil? || location.empty?
254
+
255
+ id = location.split("/").last
256
+
257
+ if location.include?("/TaskService/Tasks/")
258
+ wait_for_task(id)
259
+ else
260
+ wait_for_job(id) if respond_to?(:wait_for_job)
261
+ end
262
+ end
263
+
264
+ private
265
+
266
+ def _perform_authenticated_request(method, path, options = {}, retry_count = 0)
267
+ if retry_count >= @retry_count
268
+ debug "Maximum retry count reached", 1, :red
269
+ raise Error, "Failed to authenticate after #{@retry_count} retries"
270
+ end
271
+
272
+ debug "Authenticated request: #{method.to_s.upcase} #{path}", 1
273
+
274
+ body = options[:body]
275
+ headers = options[:headers] || {}
276
+ timeout = options[:timeout]
277
+ open_timeout = options[:open_timeout]
278
+
279
+ headers['User-Agent'] ||= 'Supermicro Ruby Client'
280
+ headers['Accept'] ||= 'application/json'
281
+ headers['Host'] = @host_header if @host_header
282
+
283
+ if body && @verbosity >= 2
284
+ debug "Request body: #{body}", 2
285
+ end
286
+
287
+ if @direct_mode
288
+ headers['Authorization'] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
289
+ debug "Using Basic Auth for request (direct mode)", 2
290
+ elsif session.x_auth_token
291
+ headers['X-Auth-Token'] = session.x_auth_token
292
+ debug "Using X-Auth-Token for authentication", 2
293
+ end
294
+
295
+ response = make_request_with_timeouts(method, path, body, headers, timeout, open_timeout)
296
+
297
+ case response.status
298
+ when 301, 302, 303, 307, 308
299
+ # Handle redirects
300
+ if response.headers['location']
301
+ new_path = response.headers['location']
302
+ # If it's a relative path, keep it as is; if absolute URL, extract the path
303
+ if new_path.start_with?('http')
304
+ new_path = URI.parse(new_path).path
305
+ end
306
+ debug "Redirecting to: #{new_path}", 2
307
+ return _perform_authenticated_request(method, new_path, options, retry_count)
308
+ else
309
+ response
310
+ end
311
+ when 401, 403
312
+ handle_auth_failure(method, path, options, retry_count)
313
+ else
314
+ debug "Response status: #{response.status}", 2
315
+ response
316
+ end
317
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
318
+ handle_connection_error(e, method, path, options, retry_count)
319
+ rescue => e
320
+ handle_general_error(e, method, path, options, retry_count)
321
+ end
322
+
323
+ def make_request_with_timeouts(method, path, body, headers, timeout, open_timeout)
324
+ conn = session.connection
325
+ original_timeout = conn.options.timeout
326
+ original_open_timeout = conn.options.open_timeout
327
+
328
+ begin
329
+ conn.options.timeout = timeout if timeout
330
+ conn.options.open_timeout = open_timeout if open_timeout
331
+
332
+ conn.run_request(method, path, body, headers)
333
+ ensure
334
+ conn.options.timeout = original_timeout
335
+ conn.options.open_timeout = original_open_timeout
336
+ end
337
+ end
338
+
339
+ def handle_auth_failure(method, path, options, retry_count)
340
+ if @direct_mode
341
+ debug "Authentication failed in direct mode, retrying...", 1, :light_yellow
342
+ sleep(retry_count + 1)
343
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
344
+ else
345
+ debug "Session expired, creating new session...", 1, :light_yellow
346
+ session.delete if session.x_auth_token
347
+
348
+ if session.create
349
+ debug "New session created, retrying request...", 1, :green
350
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
351
+ else
352
+ debug "Session creation failed, falling back to direct mode...", 1, :light_yellow
353
+ @direct_mode = true
354
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
355
+ end
356
+ end
357
+ end
358
+
359
+ def handle_connection_error(error, method, path, options, retry_count)
360
+ debug "Connection error: #{error.message}", 1, :red
361
+ sleep(retry_count + 1)
362
+
363
+ if @direct_mode || session.x_auth_token
364
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
365
+ elsif session.create
366
+ debug "Created new session after connection error", 1, :green
367
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
368
+ else
369
+ @direct_mode = true
370
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
371
+ end
372
+ end
373
+
374
+ def handle_general_error(error, method, path, options, retry_count)
375
+ debug "Error during request: #{error.message}", 1, :red
376
+
377
+ if @direct_mode
378
+ raise Error, "Error during authenticated request: #{error.message}"
379
+ elsif session.create
380
+ debug "Created new session after error, retrying...", 1, :green
381
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
382
+ else
383
+ @direct_mode = true
384
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
385
+ end
386
+ end
387
+
388
+ def _perform_get(path:, headers: {})
389
+ debug "GET request to #{base_url}/#{path}", 1
390
+
391
+ headers_to_use = {
392
+ "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36",
393
+ "Accept-Encoding" => "deflate, gzip"
394
+ }
395
+ headers_to_use["Host"] = @host_header if @host_header
396
+
397
+ if @direct_mode
398
+ headers_to_use["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
399
+ debug "Using Basic Auth for GET request", 2
400
+ elsif session.x_auth_token
401
+ headers_to_use["X-Auth-Token"] = session.x_auth_token
402
+ debug "Using X-Auth-Token for GET request", 2
403
+ end
404
+
405
+ debug "Request headers: #{headers_to_use.merge(headers).inspect}", 3
406
+
407
+ response = HTTParty.get(
408
+ "#{base_url}/#{path}",
409
+ headers: headers_to_use.merge(headers),
410
+ verify: false
411
+ )
412
+
413
+ debug "Response status: #{response.code}", 1
414
+ debug "Response headers: #{response.headers.inspect}", 2
415
+ debug "Response body: #{response.body.to_s[0..500]}#{response.body.to_s.length > 500 ? '...' : ''}", 3 if response.body
416
+
417
+ response
418
+ end
419
+ end
420
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supermicro
4
+ class Error < StandardError; end
5
+ class AuthenticationError < Error; end
6
+ class ConnectionError < Error; end
7
+ class NotFoundError < Error; end
8
+ class TimeoutError < Error; end
9
+ class BadRequestError < Error; end
10
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'colorize'
5
+
6
+ module Supermicro
7
+ module Jobs
8
+ def jobs
9
+ response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks?$expand=*($levels=1)")
10
+
11
+ if response.status == 200
12
+ begin
13
+ data = JSON.parse(response.body)
14
+
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
+
28
+ return tasks
29
+ rescue JSON::ParserError
30
+ raise Error, "Failed to parse tasks response: #{response.body}"
31
+ end
32
+ else
33
+ []
34
+ end
35
+ end
36
+
37
+ def job_status(job_id)
38
+ response = authenticated_request(:get, "/redfish/v1/TaskService/Tasks/#{job_id}")
39
+
40
+ if response.status == 200
41
+ begin
42
+ data = JSON.parse(response.body)
43
+
44
+ {
45
+ "id" => data["Id"],
46
+ "name" => data["Name"],
47
+ "state" => data["TaskState"],
48
+ "status" => data["TaskStatus"],
49
+ "percent_complete" => data["PercentComplete"] || data.dig("Oem", "Supermicro", "PercentComplete"),
50
+ "start_time" => data["StartTime"],
51
+ "end_time" => data["EndTime"],
52
+ "messages" => data["Messages"]
53
+ }
54
+ rescue JSON::ParserError
55
+ raise Error, "Failed to parse job status response: #{response.body}"
56
+ end
57
+ else
58
+ raise Error, "Failed to get job status. Status code: #{response.status}"
59
+ end
60
+ end
61
+
62
+ def wait_for_job(job_id, timeout: 600)
63
+ start_time = Time.now
64
+
65
+ puts "Waiting for job #{job_id} to complete...".yellow
66
+
67
+ loop do
68
+ if Time.now - start_time > timeout
69
+ raise Error, "Job #{job_id} timed out after #{timeout} seconds"
70
+ end
71
+
72
+ begin
73
+ status = job_status(job_id)
74
+
75
+ case status["state"]
76
+ when "Completed", "Killed", "Exception", "Cancelled"
77
+ if status["status"] == "OK" || status["status"] == "Completed"
78
+ puts "Job completed successfully.".green
79
+ return { status: :success, job: status }
80
+ else
81
+ puts "Job failed: #{status["status"]}".red
82
+ return { status: :failed, job: status, error: status["messages"] }
83
+ end
84
+ when "Running", "Starting", "New", "Pending"
85
+ percent = status["percent_complete"]
86
+ if percent
87
+ puts "Job progress: #{percent}%".cyan
88
+ else
89
+ puts "Job is #{status["state"]}...".cyan
90
+ end
91
+ sleep 5
92
+ else
93
+ puts "Unknown job state: #{status["state"]}".yellow
94
+ sleep 5
95
+ end
96
+ rescue => e
97
+ debug "Error checking job status: #{e.message}", 1, :yellow
98
+ sleep 5
99
+ end
100
+ end
101
+ end
102
+
103
+ def cancel_job(job_id)
104
+ puts "Cancelling job #{job_id}...".yellow
105
+
106
+ response = authenticated_request(
107
+ :delete,
108
+ "/redfish/v1/TaskService/Tasks/#{job_id}"
109
+ )
110
+
111
+ if response.status.between?(200, 299)
112
+ puts "Job cancelled successfully.".green
113
+ return true
114
+ else
115
+ raise Error, "Failed to cancel job: #{response.status} - #{response.body}"
116
+ end
117
+ end
118
+
119
+ def clear_completed_jobs
120
+ all_jobs = jobs
121
+ completed = all_jobs.select { |j| j["state"] == "Completed" }
122
+
123
+ if completed.empty?
124
+ puts "No completed jobs to clear.".yellow
125
+ return true
126
+ end
127
+
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
143
+ end
144
+ rescue => e
145
+ puts " Error clearing #{job["id"]}: #{e.message}".red
146
+ success = false
147
+ end
148
+ end
149
+
150
+ success
151
+ end
152
+
153
+ def jobs_summary
154
+ all_jobs = jobs
155
+
156
+ puts "\n=== Jobs Summary ===".green
157
+
158
+ if all_jobs.empty?
159
+ puts "No jobs found.".yellow
160
+ return all_jobs
161
+ end
162
+
163
+ by_state = all_jobs.group_by { |j| j["state"] }
164
+
165
+ by_state.each do |state, state_jobs|
166
+ puts "\n#{state}:".cyan
167
+ state_jobs.each do |job|
168
+ percent = job["percent_complete"] ? " (#{job["percent_complete"]}%)" : ""
169
+ puts " #{job["name"]} - #{job["status"]}#{percent}".light_cyan
170
+ puts " ID: #{job["id"]}"
171
+ puts " Started: #{job["start_time"]}" if job["start_time"]
172
+ puts " Ended: #{job["end_time"]}" if job["end_time"]
173
+ end
174
+ end
175
+
176
+ all_jobs
177
+ end
178
+ end
179
+ end