idrac 0.7.8 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4a991e250b073d8337d02439ef78b0456eda253169b32ff6c0fa0f55a0884f0
4
- data.tar.gz: c95d65eb76ffac9a349f16d24bbb7f7fb2f137f9b1c6c0071e9f8ce4b78c61d3
3
+ metadata.gz: e60a1c39738e830b95e2e0c29ad603ab9c57966535ca40374b31cc37315f4138
4
+ data.tar.gz: 6b007a005002ff5eaf88561c3e6d717c1c6d51715aa6a994a4b72e86f1f6e2c8
5
5
  SHA512:
6
- metadata.gz: 6493b01742f6f63021152a8d70a6f932396774a83f9e41f916825786ccde2257a09f96cc7144d78470b5d623cb32876c6f49534e941d81894c7c06a933bd9eed
7
- data.tar.gz: 9576c7a48cf53755457c9f0f70b17fdc40d7e02985c08294b9845fa068ca8d9c0a68e4379072e4ceb62e88718540cc9ba9a0cf1766cfb245b7cf715516a98948
6
+ metadata.gz: 2b3014bfeb1061b6506fb9025885283015d7cd6a87868c41c832de71fdd5428dd96d6f16eb18aa2ea20ede8cb4d58d87d4f2922c2fed10958ad33ebc533469c4
7
+ data.tar.gz: f20ddd6b1be1f3105aead4387865e9b556cda333df23ba944915aa7805d83a28fef85a0acc7c33456b4d102d9f707dc0d4a70db470dd6726bfea08eaefd2ffd7
data/README.md CHANGED
@@ -210,6 +210,60 @@ client = IDRAC.new(
210
210
  )
211
211
  ```
212
212
 
213
+ ### Basic Usage (Manual Session Management)
214
+
215
+ For manual control over session lifecycle:
216
+
217
+ ```ruby
218
+ require 'idrac'
219
+
220
+ client = IDRAC::Client.new(
221
+ host: "192.168.1.100",
222
+ username: "root",
223
+ password: "calvin"
224
+ )
225
+
226
+ # Always remember to logout to clean up sessions
227
+ begin
228
+ client.login
229
+ puts client.get_power_state
230
+ puts client.system_info
231
+ ensure
232
+ client.logout
233
+ end
234
+ ```
235
+
236
+ ### Block-based Usage (Recommended)
237
+
238
+ The gem provides a block-based API that automatically handles session cleanup:
239
+
240
+ ```ruby
241
+ require 'idrac'
242
+
243
+ # Using IDRAC.connect - automatically handles login/logout
244
+ IDRAC.connect(host: "192.168.1.100", username: "root", password: "calvin") do |client|
245
+ puts client.get_power_state
246
+ puts client.system_info
247
+ # Session is automatically cleaned up when block exits
248
+ end
249
+
250
+ # Or using Client.connect
251
+ IDRAC::Client.connect(host: "192.168.1.100", username: "root", password: "calvin") do |client|
252
+ puts client.get_power_state
253
+ # Session cleanup is guaranteed
254
+ end
255
+ ```
256
+
257
+ ### Automatic Session Cleanup
258
+
259
+ The gem now includes automatic session cleanup mechanisms:
260
+
261
+ 1. **Finalizer**: Sessions are automatically cleaned up when the client object is garbage collected
262
+ 2. **Block-based API**: The `connect` method ensures sessions are cleaned up even if exceptions occur
263
+ 3. **Manual cleanup**: You can still call `client.logout` manually
264
+
265
+ This prevents the "RAC0218: The maximum number of user sessions is reached" error that occurred when sessions were not properly cleaned up.
266
+
213
267
  ## Development
214
268
 
215
269
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/bin/idrac CHANGED
@@ -22,7 +22,7 @@ module IDRAC
22
22
  class_option :port, type: :numeric, default: 443, desc: "iDRAC port"
23
23
  class_option :no_ssl, type: :boolean, default: false, desc: "Disable SSL"
24
24
  class_option :verify_ssl, type: :boolean, default: false, desc: "Enable SSL verification (not recommended for iDRAC's self-signed certificates)"
25
- class_option :auto_delete_sessions, type: :boolean, default: true, desc: "Automatically delete sessions when maximum sessions are reached"
25
+
26
26
  class_option :retries, type: :numeric, default: 3, desc: "Number of retries for API calls"
27
27
  class_option :retry_delay, type: :numeric, default: 1, desc: "Initial delay in seconds between retries (increases exponentially)"
28
28
  class_option :verbose, type: :boolean, default: false, aliases: '-v', desc: "Enable verbose output"
@@ -1715,7 +1715,6 @@ module IDRAC
1715
1715
  port: options[:port],
1716
1716
  use_ssl: !options[:no_ssl],
1717
1717
  verify_ssl: options[:verify_ssl],
1718
- auto_delete_sessions: options[:auto_delete_sessions],
1719
1718
  retry_count: options[:retries],
1720
1719
  retry_delay: options[:retry_delay]
1721
1720
  )
data/lib/idrac/client.rb CHANGED
@@ -9,11 +9,10 @@ require 'colorize'
9
9
 
10
10
  module IDRAC
11
11
  class Client
12
- attr_reader :host, :username, :password, :port, :use_ssl, :verify_ssl, :auto_delete_sessions, :session, :web, :host_header
12
+ attr_reader :host, :username, :password, :port, :use_ssl, :verify_ssl, :session, :web, :host_header
13
13
  attr_accessor :direct_mode, :verbosity, :retry_count, :retry_delay
14
14
 
15
15
  include Power
16
- include SessionUtils
17
16
  include Debuggable
18
17
  include Jobs
19
18
  include Lifecycle
@@ -25,7 +24,7 @@ module IDRAC
25
24
  include SystemConfig
26
25
  include Utility
27
26
 
28
- def initialize(host:, username:, password:, port: 443, use_ssl: true, verify_ssl: false, direct_mode: false, auto_delete_sessions: true, retry_count: 3, retry_delay: 1, host_header: nil)
27
+ 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)
29
28
  @host = host
30
29
  @username = username
31
30
  @password = password
@@ -33,7 +32,6 @@ module IDRAC
33
32
  @use_ssl = use_ssl
34
33
  @verify_ssl = verify_ssl
35
34
  @direct_mode = direct_mode
36
- @auto_delete_sessions = auto_delete_sessions
37
35
  @host_header = host_header
38
36
  @verbosity = 0
39
37
  @retry_count = retry_count
@@ -42,6 +40,34 @@ module IDRAC
42
40
  # Initialize the session and web classes
43
41
  @session = Session.new(self)
44
42
  @web = Web.new(self)
43
+
44
+ # Add finalizer to ensure sessions are cleaned up
45
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@session, @web))
46
+ end
47
+
48
+ # Finalizer to clean up sessions when object is garbage collected
49
+ def self.finalizer(session, web)
50
+ proc do
51
+ begin
52
+ session.delete if session.x_auth_token
53
+ web.logout if web.session_id
54
+ rescue
55
+ # Ignore errors during cleanup
56
+ end
57
+ end
58
+ end
59
+
60
+ # Primary interface - block-based API that ensures session cleanup
61
+ def self.connect(host:, username:, password:, **options)
62
+ client = new(host: host, username: username, password: password, **options)
63
+ return client unless block_given?
64
+
65
+ begin
66
+ client.login
67
+ yield client
68
+ ensure
69
+ client.logout
70
+ end
45
71
  end
46
72
 
47
73
  def connection
@@ -87,9 +113,17 @@ module IDRAC
87
113
  end
88
114
 
89
115
  # Send an authenticated request to the iDRAC
90
- def authenticated_request(method, path, options = {})
116
+ def authenticated_request(method, path, body: nil, headers: {}, timeout: nil, open_timeout: nil, **options)
117
+ # Build options hash with all parameters
118
+ request_options = {
119
+ body: body,
120
+ headers: headers,
121
+ timeout: timeout,
122
+ open_timeout: open_timeout
123
+ }.merge(options).compact
124
+
91
125
  with_retries do
92
- _perform_authenticated_request(method, path, options)
126
+ _perform_authenticated_request(method, path, request_options)
93
127
  end
94
128
  end
95
129
 
@@ -109,145 +143,112 @@ module IDRAC
109
143
  raise Error, "Failed to authenticate after #{@retry_count} retries"
110
144
  end
111
145
 
112
- # Form the full URL
113
- full_url = "#{base_url}/redfish/v1".chomp('/') + '/' + path.sub(/^\//, '')
114
-
115
- # Log the request
116
146
  debug "Authenticated request: #{method.to_s.upcase} #{path}", 1
117
147
 
118
- # Extract options
148
+ # Extract options and prepare headers
119
149
  body = options[:body]
120
150
  headers = options[:headers] || {}
151
+ timeout = options[:timeout]
152
+ open_timeout = options[:open_timeout]
121
153
 
122
- # Add client headers
123
154
  headers['User-Agent'] ||= 'iDRAC Ruby Client'
124
155
  headers['Accept'] ||= 'application/json'
125
156
  headers['Host'] = @host_header if @host_header
126
157
 
127
- # If we're in direct mode, use Basic Auth
158
+ # Determine authentication method and set headers
128
159
  if @direct_mode
129
- # Create Basic Auth header
130
- auth_header = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
131
- headers['Authorization'] = auth_header
160
+ headers['Authorization'] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
132
161
  debug "Using Basic Auth for request (direct mode)", 2
133
-
134
- begin
135
- # Make the request directly
136
- response = session.connection.run_request(
137
- method,
138
- path.sub(/^\//, ''),
139
- body,
140
- headers
141
- )
142
-
143
- debug "Response status: #{response.status}", 2
144
-
145
- # Even in direct mode, check for authentication issues
146
- if response.status == 401 || response.status == 403
147
- debug "Authentication failed in direct mode, retrying with new credentials...", 1, :light_yellow
148
- sleep(retry_count + 1) # Add some delay before retry
149
- return _perform_authenticated_request(method, path, options, retry_count + 1)
150
- end
151
-
152
- return response
153
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
154
- # Old iDRACs (e.g. R630s) can have occasional connection issues--even SSLError is common
155
- debug "Connection error in direct mode: #{e.message}", 1, :red
156
- sleep(retry_count + 1) # Add some delay before retry
157
- return _perform_authenticated_request(method, path, options, retry_count + 1)
158
- rescue => e
159
- debugger
160
- debug "Error during direct mode request: #{e.message}", 1, :red
161
- # sleep(retry_count + 1) # Add some delay before retry
162
- # return _perform_authenticated_request(method, path, options, retry_count + 1)
163
- raise Error, "Error during authenticated request: #{e.message}"
164
- end
165
- # Use Redfish session token if available
166
162
  elsif session.x_auth_token
167
- begin
168
- headers['X-Auth-Token'] = session.x_auth_token
169
-
170
- debug "Using X-Auth-Token for authentication", 2
171
- debug "Request headers: #{headers.reject { |k,v| k =~ /auth/i }.to_json}", 3
172
- debug "Request body: #{body.to_s[0..500]}", 3 if body
173
-
174
- response = session.connection.run_request(
175
- method,
176
- path.sub(/^\//, ''),
177
- body,
178
- headers
179
- )
180
-
181
- debug "Response status: #{response.status}", 2
182
- debug "Response headers: #{response.headers.to_json}", 3
183
- debug "Response body: #{response.body.to_s[0..500]}", 3 if response.body
184
-
185
- # Handle session expiration
186
- if response.status == 401 || response.status == 403
187
- debug "Session expired or invalid, creating a new session...", 1, :light_yellow
188
-
189
- # If session.delete returns true, the session was successfully deleted
190
- if session.delete
191
- debug "Successfully cleared expired session", 1, :green
192
- end
193
-
194
- # Try to create a new session
195
- if session.create
196
- debug "Successfully created a new session after expiration, retrying request...", 1, :green
197
- return _perform_authenticated_request(method, path, options, retry_count + 1)
198
- else
199
- debug "Failed to create a new session after expiration, falling back to direct mode...", 1, :light_yellow
200
- @direct_mode = true
201
- return _perform_authenticated_request(method, path, options, retry_count + 1)
202
- end
203
- end
204
-
205
- return response
206
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
207
- debug "Connection error: #{e.message}", 1, :red
208
- sleep(retry_count + 1) # Add some delay before retry
209
-
210
- # If we still have the token, try to reuse it
211
- if session.x_auth_token
212
- debug "Retrying with existing token after connection error", 1, :light_yellow
213
- return _perform_authenticated_request(method, path, options, retry_count + 1)
214
- else
215
- # Otherwise try to create a new session
216
- debug "Trying to create a new session after connection error", 1, :light_yellow
217
- if session.create
218
- debug "Successfully created a new session after connection error", 1, :green
219
- return _perform_authenticated_request(method, path, options, retry_count + 1)
220
- else
221
- debug "Failed to create session after connection error, falling back to direct mode", 1, :light_yellow
222
- @direct_mode = true
223
- return _perform_authenticated_request(method, path, options, retry_count + 1)
224
- end
225
- end
226
- rescue => e
227
- debug "Error during authenticated request (token mode): #{e.message}", 1, :red
228
-
229
- # Try to create a new session
230
- if session.create
231
- debug "Successfully created a new session after error, retrying request...", 1, :green
232
- return _perform_authenticated_request(method, path, options, retry_count + 1)
233
- else
234
- debug "Failed to create a new session after error, falling back to direct mode...", 1, :light_yellow
235
- @direct_mode = true
236
- return _perform_authenticated_request(method, path, options, retry_count + 1)
237
- end
238
- end
163
+ headers['X-Auth-Token'] = session.x_auth_token
164
+ debug "Using X-Auth-Token for authentication", 2
165
+ end
166
+
167
+ # Make request with timeout handling
168
+ response = make_request_with_timeouts(method, path, body, headers, timeout, open_timeout)
169
+
170
+ # Handle authentication and connection errors
171
+ case response.status
172
+ when 401, 403
173
+ handle_auth_failure(method, path, options, retry_count)
174
+ else
175
+ debug "Response status: #{response.status}", 2
176
+ response
177
+ end
178
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
179
+ handle_connection_error(e, method, path, options, retry_count)
180
+ rescue => e
181
+ handle_general_error(e, method, path, options, retry_count)
182
+ end
183
+
184
+ # Make request with timeout handling
185
+ def make_request_with_timeouts(method, path, body, headers, timeout, open_timeout)
186
+ conn = session.connection
187
+ original_timeout = conn.options.timeout
188
+ original_open_timeout = conn.options.open_timeout
189
+
190
+ begin
191
+ conn.options.timeout = timeout if timeout
192
+ conn.options.open_timeout = open_timeout if open_timeout
193
+
194
+ conn.run_request(method, path.sub(/^\//, ''), body, headers)
195
+ ensure
196
+ conn.options.timeout = original_timeout
197
+ conn.options.open_timeout = original_open_timeout
198
+ end
199
+ end
200
+
201
+ # Handle authentication failures
202
+ def handle_auth_failure(method, path, options, retry_count)
203
+ if @direct_mode
204
+ debug "Authentication failed in direct mode, retrying...", 1, :light_yellow
205
+ sleep(retry_count + 1)
206
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
239
207
  else
240
- # If we don't have a token, try to create a session
208
+ debug "Session expired, creating new session...", 1, :light_yellow
209
+ session.delete if session.x_auth_token
210
+
241
211
  if session.create
242
- debug "Successfully created a new session, making request...", 1, :green
212
+ debug "New session created, retrying request...", 1, :green
243
213
  return _perform_authenticated_request(method, path, options, retry_count + 1)
244
214
  else
245
- debug "Failed to create a session, falling back to direct mode...", 1, :light_yellow
215
+ debug "Session creation failed, falling back to direct mode...", 1, :light_yellow
246
216
  @direct_mode = true
247
217
  return _perform_authenticated_request(method, path, options, retry_count + 1)
248
218
  end
249
219
  end
250
220
  end
221
+
222
+ # Handle connection errors
223
+ def handle_connection_error(error, method, path, options, retry_count)
224
+ debug "Connection error: #{error.message}", 1, :red
225
+ sleep(retry_count + 1)
226
+
227
+ if @direct_mode || session.x_auth_token
228
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
229
+ elsif session.create
230
+ debug "Created new session after connection error", 1, :green
231
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
232
+ else
233
+ @direct_mode = true
234
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
235
+ end
236
+ end
237
+
238
+ # Handle general errors
239
+ def handle_general_error(error, method, path, options, retry_count)
240
+ debug "Error during request: #{error.message}", 1, :red
241
+
242
+ if @direct_mode
243
+ raise Error, "Error during authenticated request: #{error.message}"
244
+ elsif session.create
245
+ debug "Created new session after error, retrying...", 1, :green
246
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
247
+ else
248
+ @direct_mode = true
249
+ return _perform_authenticated_request(method, path, options, retry_count + 1)
250
+ end
251
+ end
251
252
 
252
253
  def _perform_get(path:, headers: {})
253
254
  # For screenshot functionality, we need to use the WebUI cookies
data/lib/idrac/session.rb CHANGED
@@ -9,7 +9,7 @@ require 'socket'
9
9
  module IDRAC
10
10
  class Session
11
11
  attr_reader :host, :username, :password, :port, :use_ssl, :verify_ssl,
12
- :x_auth_token, :session_location, :direct_mode, :auto_delete_sessions, :host_header
12
+ :x_auth_token, :session_location, :direct_mode, :host_header
13
13
  attr_accessor :verbosity
14
14
 
15
15
  include Debuggable
@@ -27,7 +27,6 @@ module IDRAC
27
27
  @session_location = nil
28
28
  @direct_mode = client.direct_mode
29
29
  @sessions_maxed = false
30
- @auto_delete_sessions = client.auto_delete_sessions
31
30
  @verbosity = client.respond_to?(:verbosity) ? client.verbosity : 0
32
31
  end
33
32
 
@@ -76,7 +75,7 @@ module IDRAC
76
75
 
77
76
  false
78
77
  end
79
-
78
+
80
79
  # Delete all sessions using Basic Authentication
81
80
  def delete_all_sessions_with_basic_auth
82
81
  debug "Attempting to delete all sessions using Basic Authentication...", 1
@@ -143,7 +142,7 @@ module IDRAC
143
142
  return try_delete_latest_sessions
144
143
  end
145
144
  end
146
-
145
+
147
146
  # Try to delete sessions by direct URL when we can't list sessions
148
147
  def try_delete_latest_sessions
149
148
  # Try to delete sessions by direct URL when we can't list sessions
@@ -209,71 +208,24 @@ module IDRAC
209
208
  begin
210
209
  debug "Deleting Redfish session...", 1
211
210
 
212
- if @session_location
213
- # Use the X-Auth-Token for authentication
214
- headers = { 'X-Auth-Token' => @x_auth_token }
215
-
216
- begin
217
- response = connection.delete(@session_location) do |req|
218
- req.headers.merge!(headers)
219
- end
220
-
221
- if response.status == 200 || response.status == 204
222
- debug "Redfish session deleted successfully", 1, :green
223
- @x_auth_token = nil
224
- @session_location = nil
225
- return true
226
- end
227
- rescue => session_e
228
- debug "Error during session deletion via location: #{session_e.message}", 1, :yellow
229
- # Continue to try basic auth method
230
- end
211
+ # Try to delete via session location first
212
+ if @session_location && delete_via_location
213
+ return true
231
214
  end
232
215
 
233
- # If deleting via session location fails or there's no session location,
234
- # try to delete by using the basic auth method
235
- if @x_auth_token
236
- # Try to determine session ID from the X-Auth-Token or session_location
237
- session_id = nil
238
-
239
- # Extract session ID from location if available
240
- if @session_location
241
- if @session_location =~ /\/([^\/]+)$/
242
- session_id = $1
243
- end
244
- end
245
-
246
- # If we have an extracted session ID
247
- if session_id
248
- debug "Trying to delete session by ID #{session_id}", 1
249
-
250
- begin
251
- endpoint = determine_session_endpoint
252
- delete_url = "#{endpoint}/#{session_id}"
253
-
254
- delete_response = request_with_basic_auth(:delete, delete_url, nil)
255
-
256
- if delete_response.status == 200 || delete_response.status == 204
257
- debug "Successfully deleted session via ID", 1, :green
258
- @x_auth_token = nil
259
- @session_location = nil
260
- return true
261
- end
262
- rescue => id_e
263
- debug "Error during session deletion via ID: #{id_e.message}", 1, :yellow
264
- end
265
- end
266
-
267
- # Last resort: clear the token variable even if we couldn't properly delete it
268
- debug "Clearing session token internally", 1, :yellow
269
- @x_auth_token = nil
270
- @session_location = nil
216
+ # Try to delete via session ID
217
+ if @x_auth_token && delete_via_session_id
218
+ return true
271
219
  end
272
220
 
221
+ # Clear token variables even if deletion failed
222
+ debug "Clearing session token internally", 1, :yellow
223
+ @x_auth_token = nil
224
+ @session_location = nil
273
225
  return false
274
226
  rescue => e
275
227
  debug "Error during Redfish session deletion: #{e.message}", 1, :red
276
- # Clear token variable anyway
228
+ # Clear token variables anyway
277
229
  @x_auth_token = nil
278
230
  @session_location = nil
279
231
  return false
@@ -527,45 +479,39 @@ module IDRAC
527
479
  return false unless @sessions_maxed
528
480
 
529
481
  debug "Maximum sessions reached, attempting to clear sessions", 1
530
- if @auto_delete_sessions
531
- if force_clear_sessions
532
- debug "Successfully cleared sessions, trying to create a new session", 1, :green
533
-
534
- # Give the iDRAC a moment to process the session deletions
535
- sleep(3)
482
+ if force_clear_sessions
483
+ debug "Successfully cleared sessions, trying to create a new session", 1, :green
484
+
485
+ # Give the iDRAC a moment to process the session deletions
486
+ sleep(3)
487
+
488
+ # Try one more time after clearing with form-urlencoded
489
+ begin
490
+ response = connection.post(url) do |req|
491
+ req.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
492
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
493
+ req.headers['Host'] = host_header if host_header
494
+ req.body = "UserName=#{URI.encode_www_form_component(username)}&Password=#{URI.encode_www_form_component(password)}"
495
+ end
536
496
 
537
- # Try one more time after clearing with form-urlencoded
538
- begin
539
- response = connection.post(url) do |req|
540
- req.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
541
- req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
542
- req.headers['Host'] = host_header if host_header
543
- req.body = "UserName=#{URI.encode_www_form_component(username)}&Password=#{URI.encode_www_form_component(password)}"
544
- end
545
-
546
- if process_session_response(response)
547
- debug "Redfish session created successfully after clearing sessions", 1, :green
548
- return true
549
- else
550
- debug "Failed to create Redfish session after clearing sessions: #{response.status} - #{response.body}", 1, :red
551
- # If still failing, try direct mode
552
- debug "Falling back to direct mode", 1, :light_yellow
553
- @direct_mode = true
554
- return false
555
- end
556
- rescue => e
557
- debug "Error during session creation after clearing: #{e.class.name}: #{e.message}", 1, :red
497
+ if process_session_response(response)
498
+ debug "Redfish session created successfully after clearing sessions", 1, :green
499
+ return true
500
+ else
501
+ debug "Failed to create Redfish session after clearing sessions: #{response.status} - #{response.body}", 1, :red
502
+ # If still failing, try direct mode
558
503
  debug "Falling back to direct mode", 1, :light_yellow
559
504
  @direct_mode = true
560
505
  return false
561
506
  end
562
- else
563
- debug "Failed to clear sessions, switching to direct mode", 1, :light_yellow
507
+ rescue => e
508
+ debug "Error during session creation after clearing: #{e.class.name}: #{e.message}", 1, :red
509
+ debug "Falling back to direct mode", 1, :light_yellow
564
510
  @direct_mode = true
565
511
  return false
566
512
  end
567
513
  else
568
- debug "Auto delete sessions is disabled, switching to direct mode", 1, :light_yellow
514
+ debug "Failed to clear sessions, switching to direct mode", 1, :light_yellow
569
515
  @direct_mode = true
570
516
  return false
571
517
  end
@@ -635,6 +581,61 @@ module IDRAC
635
581
  end
636
582
  end
637
583
 
584
+ # Delete session using the session location URL
585
+ def delete_via_location
586
+ headers = { 'X-Auth-Token' => @x_auth_token }
587
+
588
+ response = connection.delete(@session_location) do |req|
589
+ req.headers.merge!(headers)
590
+ end
591
+
592
+ if response.status == 200 || response.status == 204
593
+ debug "Redfish session deleted successfully via location", 1, :green
594
+ @x_auth_token = nil
595
+ @session_location = nil
596
+ return true
597
+ end
598
+
599
+ false
600
+ rescue => e
601
+ debug "Error during session deletion via location: #{e.message}", 1, :yellow
602
+ false
603
+ end
604
+
605
+ # Delete session using session ID extracted from location or token
606
+ def delete_via_session_id
607
+ session_id = extract_session_id
608
+ return false unless session_id
609
+
610
+ debug "Trying to delete session by ID #{session_id}", 1
611
+
612
+ endpoint = determine_session_endpoint
613
+ delete_url = "#{endpoint}/#{session_id}"
614
+
615
+ delete_response = request_with_basic_auth(:delete, delete_url, nil)
616
+
617
+ if delete_response.status == 200 || delete_response.status == 204
618
+ debug "Successfully deleted session via ID", 1, :green
619
+ @x_auth_token = nil
620
+ @session_location = nil
621
+ return true
622
+ end
623
+
624
+ false
625
+ rescue => e
626
+ debug "Error during session deletion via ID: #{e.message}", 1, :yellow
627
+ false
628
+ end
629
+
630
+ # Extract session ID from location URL
631
+ def extract_session_id
632
+ return nil unless @session_location
633
+
634
+ if @session_location =~ /\/([^\/]+)$/
635
+ $1
636
+ end
637
+ end
638
+
638
639
  # Determine the correct session endpoint based on Redfish version
639
640
  def determine_session_endpoint
640
641
  begin
@@ -655,27 +656,21 @@ module IDRAC
655
656
 
656
657
  # For version 1.17.0 and below, use the /redfish/v1/Sessions endpoint
657
658
  # For newer versions, use /redfish/v1/SessionService/Sessions
658
- if Gem::Version.new(redfish_version) <= Gem::Version.new('1.17.0')
659
- endpoint = '/redfish/v1/Sessions'
660
- debug "Using endpoint #{endpoint} for Redfish version #{redfish_version}", 1
661
- return endpoint
662
- else
663
- endpoint = '/redfish/v1/SessionService/Sessions'
664
- debug "Using endpoint #{endpoint} for Redfish version #{redfish_version}", 1
665
- return endpoint
666
- end
659
+ endpoint = Gem::Version.new(redfish_version) <= Gem::Version.new('1.17.0') ?
660
+ '/redfish/v1/Sessions' :
661
+ '/redfish/v1/SessionService/Sessions'
662
+
663
+ debug "Using endpoint #{endpoint} for Redfish version #{redfish_version}", 1
664
+ return endpoint
667
665
  end
668
666
  rescue JSON::ParserError => e
669
667
  debug "Error parsing Redfish version: #{e.message}", 1, :red
670
- debug e.backtrace.join("\n"), 3 if e.backtrace && @verbosity >= 3
671
668
  rescue => e
672
669
  debug "Error determining Redfish version: #{e.message}", 1, :red
673
- debug e.backtrace.join("\n"), 3 if e.backtrace && @verbosity >= 3
674
670
  end
675
671
  end
676
672
  rescue => e
677
673
  debug "Error checking Redfish version: #{e.message}", 1, :red
678
- debug e.backtrace.join("\n"), 3 if e.backtrace && @verbosity >= 3
679
674
  end
680
675
 
681
676
  # Default to /redfish/v1/Sessions if we can't determine version
@@ -684,93 +679,4 @@ module IDRAC
684
679
  default_endpoint
685
680
  end
686
681
  end
687
-
688
- # Module containing extracted session methods to be included in Client
689
- module SessionUtils
690
- def force_clear_sessions
691
- debug = ->(msg, level=1, color=:light_cyan) {
692
- verbosity = respond_to?(:verbosity) ? verbosity : 0
693
- return unless verbosity >= level
694
- msg = msg.send(color) if color && msg.respond_to?(color)
695
- puts msg
696
- }
697
-
698
- debug.call "Attempting to force clear all sessions...", 1
699
-
700
- if delete_all_sessions_with_basic_auth
701
- debug.call "Successfully cleared sessions using Basic Auth", 1, :green
702
- true
703
- else
704
- debug.call "Failed to clear sessions using Basic Auth", 1, :red
705
- false
706
- end
707
- end
708
-
709
- # Delete all sessions using Basic Authentication
710
- def delete_all_sessions_with_basic_auth
711
- debug = ->(msg, level=1, color=:light_cyan) {
712
- verbosity = respond_to?(:verbosity) ? verbosity : 0
713
- return unless verbosity >= level
714
- msg = msg.send(color) if color && msg.respond_to?(color)
715
- puts msg
716
- }
717
-
718
- debug.call "Attempting to delete all sessions using Basic Authentication...", 1
719
-
720
- # First, get the list of sessions
721
- sessions_url = session&.determine_session_endpoint || '/redfish/v1/Sessions'
722
-
723
- begin
724
- # Get the list of sessions
725
- response = authenticated_request(:get, sessions_url)
726
-
727
- if response.status != 200
728
- debug.call "Failed to get sessions list: #{response.status} - #{response.body}", 1, :red
729
- return false
730
- end
731
-
732
- # Parse the response to get session IDs
733
- begin
734
- sessions_data = JSON.parse(response.body)
735
-
736
- if sessions_data['Members'] && sessions_data['Members'].any?
737
- debug.call "Found #{sessions_data['Members'].count} active sessions", 1, :light_yellow
738
-
739
- # Delete each session
740
- success = true
741
- sessions_data['Members'].each do |session|
742
- session_url = session['@odata.id']
743
-
744
- # Skip if no URL
745
- next unless session_url
746
-
747
- # Delete the session
748
- delete_response = authenticated_request(:delete, session_url)
749
-
750
- if delete_response.status == 200 || delete_response.status == 204
751
- debug.call "Successfully deleted session: #{session_url}", 1, :green
752
- else
753
- debug.call "Failed to delete session #{session_url}: #{delete_response.status}", 1, :red
754
- success = false
755
- end
756
-
757
- # Small delay between deletions
758
- sleep(1)
759
- end
760
-
761
- return success
762
- else
763
- debug.call "No active sessions found", 1, :light_yellow
764
- return true
765
- end
766
- rescue JSON::ParserError => e
767
- debug.call "Error parsing sessions response: #{e.message}", 1, :red
768
- return false
769
- end
770
- rescue => e
771
- debug.call "Error during session deletion with Basic Auth: #{e.message}", 1, :red
772
- return false
773
- end
774
- end
775
- end
776
682
  end
@@ -5,74 +5,52 @@ module IDRAC
5
5
  module SystemConfig
6
6
  # This assigns the iDRAC IP to be a STATIC IP.
7
7
  def set_idrac_ip(new_ip:, new_gw:, new_nm:, vnc_password: "calvin", vnc_port: 5901)
8
- scp = get_system_configuration_profile(target: "iDRAC")
9
- pp scp
10
- ## We want to access the iDRAC web server even when IPs don't match (and they won't when we port forward local host):
11
- set_scp_attribute(scp, "WebServer.1#HostHeaderCheck", "Disabled")
12
- ## We want VirtualMedia to be enabled so we can mount ISOs: set_scp_attribute(scp, "VirtualMedia.1#Enable", "Enabled")
13
- set_scp_attribute(scp, "VirtualMedia.1#EncryptEnable", "Disabled")
14
- ## We want to access VNC Server on specified port for screenshots and without SSL:
15
- set_scp_attribute(scp, "VNCServer.1#Enable", "Enabled")
16
- set_scp_attribute(scp, "VNCServer.1#Port", vnc_port.to_s)
17
- set_scp_attribute(scp, "VNCServer.1#SSLEncryptionBitLength", "Disabled")
18
- # And password calvin
19
- set_scp_attribute(scp, "VNCServer.1#Password", vnc_password)
20
- # Disable DHCP on management NIC
21
- set_scp_attribute(scp, "IPv4.1#DHCPEnable", "Disabled")
22
- if drac_license_version.to_i == 8
23
- # We want to use HTML for the virtual console
24
- set_scp_attribute(scp, "VirtualConsole.1#PluginType", "HTML5")
25
- # We want static IP for the iDRAC
26
- set_scp_attribute(scp, "IPv4.1#Address", new_ip)
27
- set_scp_attribute(scp, "IPv4.1#Gateway", new_gw)
28
- set_scp_attribute(scp, "IPv4.1#Netmask", new_nm)
29
- elsif drac_license_version.to_i == 9
30
- # We want static IP for the iDRAC
31
- set_scp_attribute(scp, "IPv4Static.1#Address", new_ip)
32
- set_scp_attribute(scp, "IPv4Static.1#Gateway", new_gw)
33
- set_scp_attribute(scp, "IPv4Static.1#Netmask", new_nm)
34
- # {"Name"=>"SerialCapture.1#Enable", "Value"=>"Disabled", "Set On Import"=>"True", "Comment"=>"Read and Write"},
8
+ # Cache license version to avoid multiple iDRAC calls
9
+ version = license_version.to_i
10
+
11
+ case version
12
+ when 8
13
+ ipv4_prefix = "IPv4"
14
+ settings = { "VirtualConsole.1#PluginType" => "HTML5" }
15
+ when 9
16
+ ipv4_prefix = "IPv4Static"
17
+ settings = {}
35
18
  else
36
- raise "Unknown iDRAC version"
37
- end
38
- while true
39
- res = self.post(path: "Managers/iDRAC.Embedded.1/Actions/Oem/EID_674_Manager.ImportSystemConfiguration", params: {"ImportBuffer": scp.to_json, "ShareParameters": {"Target": "iDRAC"}})
40
- # A successful JOB will have a location header with a job id.
41
- # We can get a busy message instead if we've sent too many iDRAC jobs back-to-back, so we check for that here.
42
- if res[:headers]["location"].present?
43
- # We have a job id, so we're good to go.
44
- break
45
- else
46
- # Depending on iDRAC version content-length may be present or not.
47
- # res[:headers]["content-length"].blank?
48
- msg = res['body']['error']['@Message.ExtendedInfo'].first['Message']
49
- details = res['body']['error']['@Message.ExtendedInfo'].first['Resolution']
50
- # msg => "A job operation is already running. Retry the operation after the existing job is completed."
51
- # details => "Wait until the running job is completed or delete the scheduled job and retry the operation."
52
- if details =~ /Wait until the running job is completed/
53
- sleep 10
54
- else
55
- Rails.logger.warn msg+details
56
- raise "failed configuring static ip, message: #{msg}, details: #{details}"
57
- end
58
- end
19
+ raise Error, "Unsupported iDRAC version: #{version}. Supported versions: 8, 9"
59
20
  end
60
21
 
61
- # Allow some time for the iDRAC to prepare before checking the task status
62
- sleep 3
22
+ # Build base settings for all versions
23
+ settings.merge!({
24
+ "WebServer.1#HostHeaderCheck" => "Disabled",
25
+ "VirtualMedia.1#EncryptEnable" => "Disabled",
26
+ "VNCServer.1#Enable" => "Enabled",
27
+ "VNCServer.1#Port" => vnc_port.to_s,
28
+ "VNCServer.1#SSLEncryptionBitLength" => "Disabled",
29
+ "VNCServer.1#Password" => vnc_password,
30
+ "IPv4.1#DHCPEnable" => "Disabled", # only applies to iDRAC 8
31
+ "#{ipv4_prefix}.1#Address" => new_ip, # only applies to iDRAC 9
32
+ "#{ipv4_prefix}.1#Gateway" => new_gw,
33
+ "#{ipv4_prefix}.1#Netmask" => new_nm
34
+ })
63
35
 
64
- # Use handle_location to monitor task progress
65
- result = handle_location(res[:headers]["location"])
36
+ # Build SCP from scratch instead of getting full profile
37
+ scp_component = make_scp(fqdd: "iDRAC.Embedded.1", attributes: settings)
38
+ scp = { "SystemConfiguration" => { "Components" => [scp_component] } }
66
39
 
67
- # Check if the operation succeeded
68
- if result[:status] != :success
69
- # Extract error details if available
70
- message = result[:messages].first rescue "N/A"
71
- error = result[:error] || "Unknown error"
72
- raise "Failed configuring static IP: #{message} - #{error}"
40
+ # Submit configuration with job availability handling
41
+ res = wait_for_job_availability do
42
+ authenticated_request(:post,
43
+ "/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/EID_674_Manager.ImportSystemConfiguration",
44
+ body: {"ImportBuffer": scp.to_json, "ShareParameters": {"Target": "iDRAC"}}.to_json,
45
+ headers: {"Content-Type" => "application/json"}
46
+ )
73
47
  end
74
48
 
75
- return true
49
+ sleep 3 # Allow iDRAC to prepare
50
+ result = handle_location_with_ip_change(res.headers["location"], new_ip)
51
+
52
+ raise "Failed configuring static IP: #{result[:messages]&.first || result[:error] || "Unknown error"}" if result[:status] != :success
53
+ true
76
54
  end
77
55
 
78
56
 
@@ -92,56 +70,7 @@ module IDRAC
92
70
  return scp
93
71
  end
94
72
 
95
- # Set an attribute in a system configuration profile
96
- def set_scp_attribute(scp, name, value)
97
- # Make a deep copy to avoid modifying the original
98
- scp_copy = JSON.parse(scp.to_json)
99
-
100
- # Clear unrelated attributes for quicker transfer
101
- scp_copy["SystemConfiguration"].delete("Comments")
102
- scp_copy["SystemConfiguration"].delete("TimeStamp")
103
- scp_copy["SystemConfiguration"].delete("ServiceTag")
104
- scp_copy["SystemConfiguration"].delete("Model")
105
73
 
106
- # Skip these attribute groups to make the transfer faster
107
- excluded_prefixes = [
108
- "User", "Telemetry", "SecurityCertificate", "AutoUpdate", "PCIe", "LDAP", "ADGroup", "ActiveDirectory",
109
- "IPMILan", "EmailAlert", "SNMP", "IPBlocking", "IPMI", "Security", "RFS", "OS-BMC", "SupportAssist",
110
- "Redfish", "RedfishEventing", "Autodiscovery", "SEKM-LKC", "Telco-EdgeServer", "8021XSecurity", "SPDM",
111
- "InventoryHash", "RSASecurID2FA", "USB", "NIC", "IPv6", "NTP", "Logging", "IOIDOpt", "SSHCrypto",
112
- "RemoteHosts", "SysLog", "Time", "SmartCard", "ACME", "ServiceModule", "Lockdown",
113
- "DefaultCredentialMitigation", "AutoOSLockGroup", "LocalSecurity", "IntegratedDatacenter",
114
- "SecureDefaultPassword.1#ForceChangePassword", "SwitchConnectionView.1#Enable", "GroupManager.1",
115
- "ASRConfig.1#Enable", "SerialCapture.1#Enable", "CertificateManagement.1",
116
- "Update", "SSH", "SysInfo", "GUI"
117
- ]
118
-
119
- # Remove excluded attribute groups
120
- if scp_copy["SystemConfiguration"]["Components"] &&
121
- scp_copy["SystemConfiguration"]["Components"][0] &&
122
- scp_copy["SystemConfiguration"]["Components"][0]["Attributes"]
123
-
124
- attrs = scp_copy["SystemConfiguration"]["Components"][0]["Attributes"]
125
-
126
- attrs.reject! do |attr|
127
- excluded_prefixes.any? { |prefix| attr["Name"] =~ /\A#{prefix}/ }
128
- end
129
-
130
- # Update or add the specified attribute
131
- if attrs.find { |a| a["Name"] == name }.nil?
132
- # Attribute doesn't exist, create it
133
- attrs << { "Name" => name, "Value" => value, "Set On Import" => "True" }
134
- else
135
- # Update existing attribute
136
- attrs.find { |a| a["Name"] == name }["Value"] = value
137
- attrs.find { |a| a["Name"] == name }["Set On Import"] = "True"
138
- end
139
-
140
- scp_copy["SystemConfiguration"]["Components"][0]["Attributes"] = attrs
141
- end
142
-
143
- return scp_copy
144
- end
145
74
 
146
75
  # Helper method to normalize enabled/disabled values
147
76
  def normalize_enabled_value(v)
@@ -264,52 +193,78 @@ module IDRAC
264
193
  end
265
194
  end
266
195
 
267
- # Merge two SCPs together
268
- def merge_scp(scp1, scp2)
269
- return scp1 || scp2 unless scp1 && scp2 # Return the one that's not nil if either is nil
270
-
271
- # Make them both arrays in case they aren't
272
- scp1_array = scp1.is_a?(Array) ? scp1 : [scp1]
273
- scp2_array = scp2.is_a?(Array) ? scp2 : [scp2]
274
-
275
- # Convert to hashes for merging
276
- hash1 = scp_to_hash(scp1_array)
277
- hash2 = scp_to_hash(scp2_array)
278
-
279
- # Perform deep merge
280
- merged = deep_merge(hash1, hash2)
281
-
282
- # Convert back to SCP array format
283
- hash_to_scp(merged)
284
- end
285
196
 
286
- private
287
197
 
288
- # Helper method for deep merging of hashes
289
- def deep_merge(hash1, hash2)
290
- result = hash1.dup
198
+ # Handle location header for IP change operations. Monitors old IP until it fails,
199
+ # then aggressively monitors new IP with tight timeouts.
200
+ def handle_location_with_ip_change(location, new_ip, timeout: 300)
201
+ return nil if location.nil? || location.empty?
291
202
 
292
- hash2.each do |key, value|
293
- if result[key].is_a?(Array) && value.is_a?(Array)
294
- # For arrays of attributes, merge by name
295
- existing_names = result[key].map { |attr| attr["Name"] }
203
+ old_ip = @host
204
+ start_time = Time.now
205
+ old_ip_failed = false
206
+
207
+ debug "Monitoring IP change: #{old_ip} → #{new_ip}", 1, :blue
208
+
209
+ while Time.now - start_time < timeout
210
+ # Try old IP until it fails, then focus on new IP
211
+ [
212
+ old_ip_failed ? nil : [self, old_ip, "Old IP failed"],
213
+ [create_temp_client(new_ip), new_ip, old_ip_failed ? "New IP not ready" : "Cannot reach new IP"]
214
+ ].compact.each do |client, ip, error_prefix|
296
215
 
297
- value.each do |attr|
298
- if existing_index = existing_names.index(attr["Name"])
299
- # Update existing attribute
300
- result[key][existing_index] = attr
216
+ begin
217
+ client.login if ip == new_ip
218
+ client.authenticated_request(:get, "/redfish/v1", timeout: 2, open_timeout: 1)
219
+
220
+ if ip == new_ip
221
+ debug "✅ IP change successful!", 1, :green
222
+ @host = new_ip
223
+ return { status: :success, ip: new_ip }
301
224
  else
302
- # Add new attribute
303
- result[key] << attr
225
+ return { status: :success, ip: old_ip }
304
226
  end
227
+ rescue => e
228
+ debug "#{error_prefix}: #{e.message}", ip == new_ip ? 2 : 1, ip == new_ip ? :gray : :yellow
229
+ old_ip_failed = true if ip == old_ip
305
230
  end
306
- else
307
- # For other values, just replace
308
- result[key] = value
309
231
  end
232
+
233
+ sleep old_ip_failed ? 2 : 5
310
234
  end
311
235
 
312
- result
236
+ { status: :timeout, error: "IP change timed out after #{timeout}s. Old IP failed: #{old_ip_failed}" }
313
237
  end
238
+
239
+ private
240
+
241
+ # Wait for job availability, retrying if busy
242
+ def wait_for_job_availability
243
+ loop do
244
+ res = yield
245
+ return res if res.headers["location"].present?
246
+
247
+ msg = res['error']['@Message.ExtendedInfo'].first['Message']
248
+ details = res['error']['@Message.ExtendedInfo'].first['Resolution']
249
+
250
+ if details =~ /Wait until the running job is completed/
251
+ sleep 10
252
+ else
253
+ Rails.logger.warn "#{msg}#{details}" if defined?(Rails)
254
+ raise "Failed configuring static IP: #{msg}, #{details}"
255
+ end
256
+ end
257
+ end
258
+
259
+ # Create temporary client for new IP monitoring
260
+ def create_temp_client(new_ip)
261
+ self.class.new(
262
+ host: new_ip, username: @username, password: @password,
263
+ port: @port, use_ssl: @use_ssl, verify_ssl: @verify_ssl,
264
+ retry_count: 1, retry_delay: 1
265
+ ).tap { |c| c.verbosity = [@verbosity - 1, 0].max }
266
+ end
267
+
268
+ private
314
269
  end
315
270
  end
data/lib/idrac/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IDRAC
4
- VERSION = "0.7.8"
4
+ VERSION = "0.7.9"
5
5
  end
data/lib/idrac/web.rb CHANGED
@@ -60,9 +60,9 @@ module IDRAC
60
60
  elsif response.code == 400 && response.body.include?("maximum number of user sessions")
61
61
  puts "Maximum sessions reached during WebUI login".light_red
62
62
 
63
- # Try to clear sessions if auto_delete_sessions is enabled
64
- if client.auto_delete_sessions && !@tried_clearing_sessions
65
- puts "Auto-delete sessions is enabled, attempting to clear sessions".light_cyan
63
+ # Try to clear sessions automatically
64
+ if !@tried_clearing_sessions
65
+ puts "Attempting to clear sessions automatically".light_cyan
66
66
  @tried_clearing_sessions = true
67
67
 
68
68
  if client.session.force_clear_sessions
@@ -73,7 +73,7 @@ module IDRAC
73
73
  return false
74
74
  end
75
75
  else
76
- puts "Auto-delete sessions is disabled or already tried clearing".light_yellow
76
+ puts "Already tried clearing sessions".light_yellow
77
77
  return false
78
78
  end
79
79
  else
data/lib/idrac.rb CHANGED
@@ -36,6 +36,11 @@ module IDRAC
36
36
  def self.new(options = {})
37
37
  Client.new(options)
38
38
  end
39
+
40
+ # Block-based API that ensures session cleanup
41
+ def self.connect(**options, &block)
42
+ Client.connect(**options, &block)
43
+ end
39
44
  end
40
45
 
41
46
  require 'idrac/version'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idrac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.8
4
+ version: 0.7.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Siegel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-22 00:00:00.000000000 Z
11
+ date: 2025-06-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty